Prechádzať zdrojové kódy

feat(admin-ui): Enable assigning Products to Channels

Relates to #12
Michael Bromley 6 rokov pred
rodič
commit
59b9c9157e
32 zmenil súbory, kde vykonal 707 pridanie a 95 odobranie
  1. 8 1
      packages/admin-ui/src/app/catalog/catalog.module.ts
  2. 69 0
      packages/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.html
  3. 12 0
      packages/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.scss
  4. 101 0
      packages/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts
  5. 1 1
      packages/admin-ui/src/app/catalog/components/collection-list/collection-list.component.ts
  6. 4 1
      packages/admin-ui/src/app/catalog/components/facet-list/facet-list.component.ts
  7. 21 3
      packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  8. 30 2
      packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  9. 5 3
      packages/admin-ui/src/app/catalog/components/product-list/product-list.component.ts
  10. 1 1
      packages/admin-ui/src/app/common/base-detail.component.ts
  11. 6 4
      packages/admin-ui/src/app/common/base-list.component.ts
  12. 1 1
      packages/admin-ui/src/app/common/detail-breadcrumb.ts
  13. 52 2
      packages/admin-ui/src/app/common/generated-types.ts
  14. 1 1
      packages/admin-ui/src/app/core/components/app-shell/app-shell.component.html
  15. 3 3
      packages/admin-ui/src/app/core/components/channel-switcher/channel-switcher.component.html
  16. 17 0
      packages/admin-ui/src/app/data/definitions/product-definitions.ts
  17. 12 1
      packages/admin-ui/src/app/data/providers/product-data.service.ts
  18. 1 0
      packages/admin-ui/src/app/data/query-result.ts
  19. 2 1
      packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.html
  20. 11 0
      packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.scss
  21. 21 5
      packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.ts
  22. 1 3
      packages/admin-ui/src/app/shared/components/channel-badge/channel-badge.component.html
  23. 2 6
      packages/admin-ui/src/app/shared/components/channel-badge/channel-badge.component.scss
  24. 1 1
      packages/admin-ui/src/app/shared/components/form-item/form-item.component.html
  25. 75 0
      packages/admin-ui/src/app/shared/directives/if-directive-base.ts
  26. 119 0
      packages/admin-ui/src/app/shared/directives/if-multichannel.directive.spec.ts
  27. 30 0
      packages/admin-ui/src/app/shared/directives/if-multichannel.directive.ts
  28. 68 0
      packages/admin-ui/src/app/shared/directives/if-permissions.directive.spec.ts
  29. 17 54
      packages/admin-ui/src/app/shared/directives/if-permissions.directive.ts
  30. 2 0
      packages/admin-ui/src/app/shared/shared-declarations.ts
  31. 3 1
      packages/admin-ui/src/app/shared/shared.module.ts
  32. 10 0
      packages/admin-ui/src/i18n-messages/en.json

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

@@ -8,6 +8,7 @@ import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
+import { AssignProductsToChannelDialogComponent } from './components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
 import { CollectionContentsComponent } from './components/collection-contents/collection-contents.component';
 import { CollectionDetailComponent } from './components/collection-detail/collection-detail.component';
 import { CollectionDetailComponent } from './components/collection-detail/collection-detail.component';
 import { CollectionListComponent } from './components/collection-list/collection-list.component';
 import { CollectionListComponent } from './components/collection-list/collection-list.component';
@@ -57,8 +58,14 @@ import { ProductVariantsResolver } from './providers/routing/product-variants-re
         OptionValueInputComponent,
         OptionValueInputComponent,
         UpdateProductOptionDialogComponent,
         UpdateProductOptionDialogComponent,
         ProductVariantsEditorComponent,
         ProductVariantsEditorComponent,
+        AssignProductsToChannelDialogComponent,
+    ],
+    entryComponents: [
+        ApplyFacetDialogComponent,
+        AssetPreviewComponent,
+        UpdateProductOptionDialogComponent,
+        AssignProductsToChannelDialogComponent,
     ],
     ],
-    entryComponents: [ApplyFacetDialogComponent, AssetPreviewComponent, UpdateProductOptionDialogComponent],
     providers: [
     providers: [
         ProductResolver,
         ProductResolver,
         FacetResolver,
         FacetResolver,

+ 69 - 0
packages/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.html

@@ -0,0 +1,69 @@
+<ng-template vdrDialogTitle>{{ 'catalog.assign-products-to-channel' | translate }}</ng-template>
+
+<div class="flex">
+    <clr-input-container>
+        <label>{{ 'common.channel' | translate }}</label>
+        <vdr-channel-assignment-control
+            clrInput
+            [multiple]="false"
+            [includeDefaultChannel]="false"
+            [formControl]="selectedChannelIdControl"
+        ></vdr-channel-assignment-control>
+    </clr-input-container>
+    <div class="flex-spacer"></div>
+    <clr-input-container>
+        <label>{{ 'catalog.price-conversion-factor' | translate }}</label>
+        <input clrInput type="number" min="0" max="99999" [formControl]="priceFactorControl" />
+    </clr-input-container>
+</div>
+
+<div class="channel-price-preview">
+    <label class="clr-control-label">{{ 'catalog.channel-price-preview' | translate }}</label>
+    <table class="table">
+        <thead>
+            <tr>
+                <th>{{ 'common.name' | translate }}</th>
+                <th>
+                    {{
+                        'catalog.price-in-channel'
+                            | translate: { channel: currentChannel?.code | channelCodeToLabel | translate }
+                    }}
+                </th>
+                <th>
+                    <ng-template [ngIf]="selectedChannel" [ngIfElse]="noSelection">
+                        {{ 'catalog.price-in-channel' | translate: { channel: selectedChannel?.code } }}
+                    </ng-template>
+                    <ng-template #noSelection>
+                        {{ 'catalog.no-channel-selected' | translate }}
+                    </ng-template>
+                </th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr *ngFor="let row of variantsPreview$ | async">
+                <td>{{ row.name }}</td>
+                <td>{{ row.price / 100 | currency: currentChannel?.currencyCode }}</td>
+                <td>
+                    <ng-template [ngIf]="selectedChannel" [ngIfElse]="noChannelSelected">
+                        {{ row.pricePreview / 100 | currency: selectedChannel?.currencyCode }}
+                    </ng-template>
+                    <ng-template #noChannelSelected>
+                        -
+                    </ng-template>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit" (click)="assign()" [disabled]="!selectedChannel" class="btn btn-primary">
+        <ng-template [ngIf]="selectedChannel" [ngIfElse]="noSelection">
+            {{ 'catalog.assign-to-named-channel' | translate: { channelCode: selectedChannel?.code } }}
+        </ng-template>
+        <ng-template #noSelection>
+            {{ 'catalog.no-channel-selected' | translate }}
+        </ng-template>
+    </button>
+</ng-template>

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

@@ -0,0 +1,12 @@
+@import "variables";
+
+vdr-channel-assignment-control {
+    min-width: 200px;
+}
+
+.channel-price-preview {
+    margin-top: 24px;
+    table.table {
+        margin-top: 6px;
+    }
+}

+ 101 - 0
packages/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component.ts

@@ -0,0 +1,101 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { NotificationService } from '@vendure/admin-ui/src/app/core/providers/notification/notification.service';
+import { combineLatest, from, Observable } from 'rxjs';
+import { map, startWith, switchMap } from 'rxjs/operators';
+
+import { GetChannels, ProductVariantFragment } from '../../../common/generated-types';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { DataService } from '../../../data/providers/data.service';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-assign-products-to-channel-dialog',
+    templateUrl: './assign-products-to-channel-dialog.component.html',
+    styleUrls: ['./assign-products-to-channel-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssignProductsToChannelDialogComponent implements OnInit, Dialog<any> {
+    selectedChannel: GetChannels.Channels | null | undefined;
+    currentChannel: GetChannels.Channels;
+    availableChannels: GetChannels.Channels[];
+    resolveWith: (result?: any) => void;
+    variantsPreview$: Observable<Array<{ id: string; name: string; price: number; pricePreview: number }>>;
+    priceFactorControl = new FormControl(1);
+    selectedChannelIdControl = new FormControl();
+
+    // assigned by ModalService.fromComponent() call
+    productIds: string[];
+
+    constructor(private dataService: DataService, private notificationService: NotificationService) {}
+
+    ngOnInit() {
+        const activeChannelId$ = this.dataService.client
+            .userStatus()
+            .mapSingle(({ userStatus }) => userStatus.activeChannelId);
+        const allChannels$ = this.dataService.settings.getChannels().mapSingle(data => data.channels);
+
+        combineLatest(activeChannelId$, allChannels$).subscribe(([activeChannelId, channels]) => {
+            // tslint:disable-next-line:no-non-null-assertion
+            this.currentChannel = channels.find(c => c.id === activeChannelId)!;
+            this.availableChannels = channels;
+        });
+
+        this.selectedChannelIdControl.valueChanges.subscribe(ids => {
+            this.selectChannel(ids);
+        });
+
+        this.variantsPreview$ = combineLatest(
+            from(this.getTopVariants(10)),
+            this.priceFactorControl.valueChanges.pipe(startWith(1)),
+        ).pipe(
+            map(([variants, factor]) => {
+                return variants.map(v => ({
+                    id: v.id,
+                    name: v.name,
+                    price: v.price,
+                    pricePreview: v.price * +factor,
+                }));
+            }),
+        );
+    }
+
+    selectChannel(channelIds: string[]) {
+        this.selectedChannel = this.availableChannels.find(c => c.id === channelIds[0]);
+    }
+
+    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,
+                    });
+                    this.resolveWith(true);
+                });
+        }
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+
+    private async getTopVariants(take: number): Promise<ProductVariantFragment[]> {
+        const variants: ProductVariantFragment[] = [];
+
+        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)
+                .toPromise();
+            variants.push(...(productVariants || []));
+        }
+        return variants.slice(0, take);
+    }
+}

+ 1 - 1
packages/admin-ui/src/app/catalog/components/collection-list/collection-list.component.ts

@@ -33,7 +33,7 @@ export class CollectionListComponent implements OnInit {
     ) {}
     ) {}
 
 
     ngOnInit() {
     ngOnInit() {
-        this.queryResult = this.dataService.collection.getCollections(99999, 0);
+        this.queryResult = this.dataService.collection.getCollections(99999, 0).refetchOnChannelChange();
         this.items$ = this.queryResult.mapStream(data => data.collections.items);
         this.items$ = this.queryResult.mapStream(data => data.collections.items);
         this.activeCollectionId$ = this.route.paramMap.pipe(
         this.activeCollectionId$ = this.route.paramMap.pipe(
             map(pm => pm.get('contents')),
             map(pm => pm.get('contents')),

+ 4 - 1
packages/admin-ui/src/app/catalog/components/facet-list/facet-list.component.ts

@@ -26,7 +26,10 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
         route: ActivatedRoute,
         route: ActivatedRoute,
     ) {
     ) {
         super(router, route);
         super(router, route);
-        super.setQueryFn((...args: any[]) => this.dataService.facet.getFacets(...args), data => data.facets);
+        super.setQueryFn(
+            (...args: any[]) => this.dataService.facet.getFacets(...args).refetchOnChannelChange(),
+            data => data.facets,
+        );
     }
     }
 
 
     toggleDisplayLimit(facet: GetFacetList.Items) {
     toggleDisplayLimit(facet: GetFacetList.Items) {

+ 21 - 3
packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -55,6 +55,22 @@
                 <div class="clr-row">
                 <div class="clr-row">
                     <div class="clr-col">
                     <div class="clr-col">
                         <section class="form-block" formGroupName="product">
                         <section class="form-block" formGroupName="product">
+                            <vdr-form-item [label]="'common.channels' | translate" *vdrIfMultichannel>
+                                <div class="flex">
+                                    <div class="product-channels">
+                                        <ng-container *ngFor="let channel of productChannels$ | async">
+                                            <vdr-chip *ngIf="!isDefaultChannel(channel.code)">
+                                                <vdr-channel-badge [channelCode]="channel.code"></vdr-channel-badge>
+                                                {{ channel.code | channelCodeToLabel }}
+                                            </vdr-chip>
+                                        </ng-container>
+                                    </div>
+                                    <button class="btn btn-sm" (click)="assignToChannel()">
+                                        <clr-icon shape="layers"></clr-icon>
+                                        {{ 'catalog.assign-to-channel' | translate }}
+                                    </button>
+                                </div>
+                            </vdr-form-item>
                             <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
                             <vdr-form-field [label]="'catalog.product-name' | translate" for="name">
                                 <input
                                 <input
                                     id="name"
                                     id="name"
@@ -103,9 +119,11 @@
                                     [removable]="'UpdateCatalog' | hasPermission"
                                     [removable]="'UpdateCatalog' | hasPermission"
                                     (remove)="removeProductFacetValue(facetValue.id)"
                                     (remove)="removeProductFacetValue(facetValue.id)"
                                 ></vdr-facet-value-chip>
                                 ></vdr-facet-value-chip>
-                                <button class="btn btn-sm btn-secondary"
-                                        *vdrIfPermissions="'UpdateCatalog'"
-                                        (click)="selectProductFacetValue()">
+                                <button
+                                    class="btn btn-sm btn-secondary"
+                                    *vdrIfPermissions="'UpdateCatalog'"
+                                    (click)="selectProductFacetValue()"
+                                >
                                     <clr-icon shape="plus"></clr-icon>
                                     <clr-icon shape="plus"></clr-icon>
                                     {{ 'catalog.add-facets' | translate }}
                                     {{ 'catalog.add-facets' | translate }}
                                 </button>
                                 </button>

+ 30 - 2
packages/admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -2,9 +2,20 @@ import { Location } from '@angular/common';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActivatedRoute, Router } from '@angular/router';
+import { AssignProductsToChannelDialogComponent } from '@vendure/admin-ui/src/app/catalog/components/assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
 import { combineLatest, EMPTY, merge, Observable } from 'rxjs';
 import { combineLatest, EMPTY, merge, Observable } from 'rxjs';
-import { distinctUntilChanged, map, mergeMap, switchMap, take, withLatestFrom } from 'rxjs/operators';
+import {
+    distinctUntilChanged,
+    map,
+    mergeMap,
+    switchMap,
+    take,
+    takeUntil,
+    withLatestFrom,
+} from 'rxjs/operators';
 import { normalizeString } from 'shared/normalize-string';
 import { normalizeString } from 'shared/normalize-string';
+import { pick } from 'shared/pick';
+import { DEFAULT_CHANNEL_CODE } from 'shared/shared-constants';
 import { notNullOrUndefined } from 'shared/shared-utils';
 import { notNullOrUndefined } from 'shared/shared-utils';
 import { unique } from 'shared/unique';
 import { unique } from 'shared/unique';
 import { IGNORE_CAN_DEACTIVATE_GUARD } from 'src/app/shared/providers/routing/can-deactivate-detail-guard';
 import { IGNORE_CAN_DEACTIVATE_GUARD } from 'src/app/shared/providers/routing/can-deactivate-detail-guard';
@@ -71,6 +82,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     detailForm: FormGroup;
     detailForm: FormGroup;
     assetChanges: SelectedAssets = {};
     assetChanges: SelectedAssets = {};
     variantAssetChanges: { [variantId: string]: SelectedAssets } = {};
     variantAssetChanges: { [variantId: string]: SelectedAssets } = {};
+    productChannels$: Observable<ProductWithVariants.Channels[]>;
     facetValues$: Observable<ProductWithVariants.FacetValues[]>;
     facetValues$: Observable<ProductWithVariants.FacetValues[]>;
     facets$: Observable<FacetWithValues.Fragment[]>;
     facets$: Observable<FacetWithValues.Fragment[]>;
     selectedVariantIds: string[] = [];
     selectedVariantIds: string[] = [];
@@ -110,7 +122,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         this.init();
         this.init();
         this.product$ = this.entity$;
         this.product$ = this.entity$;
         this.variants$ = this.product$.pipe(map(product => product.variants));
         this.variants$ = this.product$.pipe(map(product => product.variants));
-        this.taxCategories$ = this.productDetailService.getTaxCategories();
+        this.taxCategories$ = this.productDetailService.getTaxCategories().pipe(takeUntil(this.destroy$));
         this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
         this.activeTab$ = this.route.paramMap.pipe(map(qpm => qpm.get('tab') as any));
 
 
         // FacetValues are provided initially by the nested array of the
         // FacetValues are provided initially by the nested array of the
@@ -138,6 +150,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         );
         );
 
 
         this.facetValues$ = merge(productFacetValues$, formChangeFacetValues$);
         this.facetValues$ = merge(productFacetValues$, formChangeFacetValues$);
+        this.productChannels$ = this.product$.pipe(map(p => p.channels));
     }
     }
 
 
     ngOnDestroy() {
     ngOnDestroy() {
@@ -153,6 +166,21 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         });
         });
     }
     }
 
 
+    isDefaultChannel(channelCode: string): boolean {
+        return channelCode === DEFAULT_CHANNEL_CODE;
+    }
+
+    assignToChannel() {
+        this.modalService
+            .fromComponent(AssignProductsToChannelDialogComponent, {
+                size: 'lg',
+                locals: {
+                    productIds: [this.id],
+                },
+            })
+            .subscribe();
+    }
+
     customFieldIsSet(name: string): boolean {
     customFieldIsSet(name: string): boolean {
         return !!this.detailForm.get(['product', 'customFields', name]);
         return !!this.detailForm.get(['product', 'customFields', name]);
     }
     }

+ 5 - 3
packages/admin-ui/src/app/catalog/components/product-list/product-list.component.ts

@@ -1,6 +1,6 @@
 import { Component, OnInit, ViewChild } from '@angular/core';
 import { Component, OnInit, ViewChild } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActivatedRoute, Router } from '@angular/router';
-import { EMPTY, Observable } from 'rxjs';
+import { EMPTY, Observable, of } from 'rxjs';
 import { delay, map, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators';
 import { delay, map, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators';
 
 
 import { BaseListComponent } from '../../../common/base-list.component';
 import { BaseListComponent } from '../../../common/base-list.component';
@@ -36,7 +36,8 @@ export class ProductListComponent
     ) {
     ) {
         super(router, route);
         super(router, route);
         super.setQueryFn(
         super.setQueryFn(
-            (...args: any[]) => this.dataService.product.searchProducts(this.searchTerm, ...args),
+            (...args: any[]) =>
+                this.dataService.product.searchProducts(this.searchTerm, ...args).refetchOnChannelChange(),
             data => data.search,
             data => data.search,
             // tslint:disable-next-line:no-shadowed-variable
             // tslint:disable-next-line:no-shadowed-variable
             (skip, take) => ({
             (skip, take) => ({
@@ -53,7 +54,8 @@ export class ProductListComponent
 
 
     ngOnInit() {
     ngOnInit() {
         super.ngOnInit();
         super.ngOnInit();
-        this.facetValues$ = this.listQuery.mapStream(data => data.search.facetValues);
+        this.facetValues$ = this.result$.pipe(map(data => data.search.facetValues));
+        // this.facetValues$ = of([]);
         this.route.queryParamMap
         this.route.queryParamMap
             .pipe(
             .pipe(
                 map(qpm => qpm.get('q')),
                 map(qpm => qpm.get('q')),

+ 1 - 1
packages/admin-ui/src/app/common/base-detail.component.ts

@@ -27,7 +27,7 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
 
 
     init() {
     init() {
         this.entity$ = this.route.data.pipe(
         this.entity$ = this.route.data.pipe(
-            switchMap(data => data.entity as Observable<Entity>),
+            switchMap(data => (data.entity as Observable<Entity>).pipe(takeUntil(this.destroy$))),
             tap(entity => (this.id = entity.id)),
             tap(entity => (this.id = entity.id)),
             shareReplay(1),
             shareReplay(1),
         );
         );

+ 6 - 4
packages/admin-ui/src/app/common/base-list.component.ts

@@ -1,7 +1,7 @@
 import { OnDestroy, OnInit } from '@angular/core';
 import { OnDestroy, OnInit } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActivatedRoute, Router } from '@angular/router';
 import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
 import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
-import { map, takeUntil } from 'rxjs/operators';
+import { map, shareReplay, takeUntil } from 'rxjs/operators';
 
 
 import { QueryResult } from '../data/query-result';
 import { QueryResult } from '../data/query-result';
 
 
@@ -14,12 +14,13 @@ export type OnPageChangeFn<V> = (skip: number, take: number) => V;
  * a list of data from a query which returns a PaginatedList type.
  * a list of data from a query which returns a PaginatedList type.
  */
  */
 export class BaseListComponent<ResultType, ItemType, VariableType = any> implements OnInit, OnDestroy {
 export class BaseListComponent<ResultType, ItemType, VariableType = any> implements OnInit, OnDestroy {
+    result$: Observable<ResultType>;
     items$: Observable<ItemType[]>;
     items$: Observable<ItemType[]>;
     totalItems$: Observable<number>;
     totalItems$: Observable<number>;
     itemsPerPage$: Observable<number>;
     itemsPerPage$: Observable<number>;
     currentPage$: Observable<number>;
     currentPage$: Observable<number>;
-    protected listQuery: QueryResult<ResultType, VariableType>;
     protected destroy$ = new Subject<void>();
     protected destroy$ = new Subject<void>();
+    private listQuery: QueryResult<ResultType, VariableType>;
     private listQueryFn: ListQueryFn<ResultType>;
     private listQueryFn: ListQueryFn<ResultType>;
     private mappingFn: MappingFn<ItemType, ResultType>;
     private mappingFn: MappingFn<ItemType, ResultType>;
     private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
     private onPageChangeFn: OnPageChangeFn<VariableType> = (skip, take) =>
@@ -57,8 +58,9 @@ export class BaseListComponent<ResultType, ItemType, VariableType = any> impleme
             this.listQuery.ref.refetch(this.onPageChangeFn(skip, take));
             this.listQuery.ref.refetch(this.onPageChangeFn(skip, take));
         };
         };
 
 
-        this.items$ = this.listQuery.stream$.pipe(map(data => this.mappingFn(data).items));
-        this.totalItems$ = this.listQuery.stream$.pipe(map(data => this.mappingFn(data).totalItems));
+        this.result$ = this.listQuery.stream$.pipe(shareReplay(1));
+        this.items$ = this.result$.pipe(map(data => this.mappingFn(data).items));
+        this.totalItems$ = this.result$.pipe(map(data => this.mappingFn(data).totalItems));
         this.currentPage$ = this.route.queryParamMap.pipe(
         this.currentPage$ = this.route.queryParamMap.pipe(
             map(qpm => qpm.get('page')),
             map(qpm => qpm.get('page')),
             map(page => (!page ? 1 : +page)),
             map(page => (!page ? 1 : +page)),

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

@@ -1,5 +1,5 @@
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
+import { map, take } from 'rxjs/operators';
 
 
 import { BreadcrumbValue } from '../core/components/breadcrumb/breadcrumb.component';
 import { BreadcrumbValue } from '../core/components/breadcrumb/breadcrumb.component';
 import { _ } from '../core/providers/i18n/mark-for-extraction';
 import { _ } from '../core/providers/i18n/mark-for-extraction';

+ 52 - 2
packages/admin-ui/src/app/common/generated-types.ts

@@ -163,6 +163,12 @@ export enum AssetType {
   BINARY = 'BINARY'
   BINARY = 'BINARY'
 }
 }
 
 
+export type AssignProductsToChannelInput = {
+  productIds: Array<Scalars['ID']>,
+  channelId: Scalars['ID'],
+  priceFactor?: Maybe<Scalars['Float']>,
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
 export type BooleanCustomFieldConfig = CustomField & {
   __typename?: 'BooleanCustomFieldConfig',
   __typename?: 'BooleanCustomFieldConfig',
   name: Scalars['String'],
   name: Scalars['String'],
@@ -1685,6 +1691,8 @@ export type Mutation = {
   createChannel: Channel,
   createChannel: Channel,
   /** Update an existing Channel */
   /** Update an existing Channel */
   updateChannel: Channel,
   updateChannel: Channel,
+  /** Delete a Channel */
+  deleteChannel: DeletionResponse,
   /** Create a new Collection */
   /** Create a new Collection */
   createCollection: Collection,
   createCollection: Collection,
   /** Update an existing Collection */
   /** Update an existing Collection */
@@ -1766,6 +1774,10 @@ export type Mutation = {
   updateProductVariants: Array<Maybe<ProductVariant>>,
   updateProductVariants: Array<Maybe<ProductVariant>>,
   /** Delete a ProductVariant */
   /** Delete a ProductVariant */
   deleteProductVariant: DeletionResponse,
   deleteProductVariant: DeletionResponse,
+  /** Assigns Products to the specified Channel */
+  assignProductsToChannel: Array<Product>,
+  /** Removes Products from the specified Channel */
+  removeProductsFromChannel: Array<Product>,
   createPromotion: Promotion,
   createPromotion: Promotion,
   updatePromotion: Promotion,
   updatePromotion: Promotion,
   deletePromotion: DeletionResponse,
   deletePromotion: DeletionResponse,
@@ -1844,6 +1856,11 @@ export type MutationUpdateChannelArgs = {
 };
 };
 
 
 
 
+export type MutationDeleteChannelArgs = {
+  id: Scalars['ID']
+};
+
+
 export type MutationCreateCollectionArgs = {
 export type MutationCreateCollectionArgs = {
   input: CreateCollectionInput
   input: CreateCollectionInput
 };
 };
@@ -2072,6 +2089,16 @@ export type MutationDeleteProductVariantArgs = {
 };
 };
 
 
 
 
+export type MutationAssignProductsToChannelArgs = {
+  input: AssignProductsToChannelInput
+};
+
+
+export type MutationRemoveProductsFromChannelArgs = {
+  input: RemoveProductsFromChannelInput
+};
+
+
 export type MutationCreatePromotionArgs = {
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput
   input: CreatePromotionInput
 };
 };
@@ -2437,6 +2464,7 @@ export type PriceRange = {
 export type Product = Node & {
 export type Product = Node & {
   __typename?: 'Product',
   __typename?: 'Product',
   enabled: Scalars['Boolean'],
   enabled: Scalars['Boolean'],
+  channels: Array<Channel>,
   id: Scalars['ID'],
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
@@ -2971,6 +2999,11 @@ export type RefundOrderInput = {
   reason?: Maybe<Scalars['String']>,
   reason?: Maybe<Scalars['String']>,
 };
 };
 
 
+export type RemoveProductsFromChannelInput = {
+  productIds: Array<Scalars['ID']>,
+  channelId: Scalars['ID'],
+};
+
 export type Return = Node & StockMovement & {
 export type Return = Node & StockMovement & {
   __typename?: 'Return',
   __typename?: 'Return',
   id: Scalars['ID'],
   id: Scalars['ID'],
@@ -3057,6 +3090,8 @@ export type SearchResponse = {
 export type SearchResult = {
 export type SearchResult = {
   __typename?: 'SearchResult',
   __typename?: 'SearchResult',
   enabled: Scalars['Boolean'],
   enabled: Scalars['Boolean'],
+  /** An array of ids of the Collections in which this result appears */
+  channelIds: Array<Scalars['ID']>,
   sku: Scalars['String'],
   sku: Scalars['String'],
   slug: Scalars['String'],
   slug: Scalars['String'],
   productId: Scalars['ID'],
   productId: Scalars['ID'],
@@ -3915,7 +3950,7 @@ export type AssetFragment = ({ __typename?: 'Asset' } & Pick<Asset, 'id' | 'crea
 
 
 export type ProductVariantFragment = ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'languageCode' | 'name' | 'price' | 'currencyCode' | 'priceIncludesTax' | 'priceWithTax' | 'stockOnHand' | 'trackInventory' | 'sku'> & { taxRateApplied: ({ __typename?: 'TaxRate' } & Pick<TaxRate, 'id' | 'name' | 'value'>), taxCategory: ({ __typename?: 'TaxCategory' } & Pick<TaxCategory, 'id' | 'name'>), options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'code' | 'languageCode' | 'name' | 'groupId'> & { translations: Array<({ __typename?: 'ProductOptionTranslation' } & Pick<ProductOptionTranslation, 'id' | 'languageCode' | 'name'>)> })>, facetValues: Array<({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) })>, featuredAsset: Maybe<({ __typename?: 'Asset' } & AssetFragment)>, assets: Array<({ __typename?: 'Asset' } & AssetFragment)>, translations: Array<({ __typename?: 'ProductVariantTranslation' } & Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>)> });
 export type ProductVariantFragment = ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'languageCode' | 'name' | 'price' | 'currencyCode' | 'priceIncludesTax' | 'priceWithTax' | 'stockOnHand' | 'trackInventory' | 'sku'> & { taxRateApplied: ({ __typename?: 'TaxRate' } & Pick<TaxRate, 'id' | 'name' | 'value'>), taxCategory: ({ __typename?: 'TaxCategory' } & Pick<TaxCategory, 'id' | 'name'>), options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'code' | 'languageCode' | 'name' | 'groupId'> & { translations: Array<({ __typename?: 'ProductOptionTranslation' } & Pick<ProductOptionTranslation, 'id' | 'languageCode' | 'name'>)> })>, facetValues: Array<({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) })>, featuredAsset: Maybe<({ __typename?: 'Asset' } & AssetFragment)>, assets: Array<({ __typename?: 'Asset' } & AssetFragment)>, translations: Array<({ __typename?: 'ProductVariantTranslation' } & Pick<ProductVariantTranslation, 'id' | 'languageCode' | 'name'>)> });
 
 
-export type ProductWithVariantsFragment = ({ __typename?: 'Product' } & Pick<Product, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'languageCode' | 'name' | 'slug' | 'description'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & AssetFragment)>, assets: Array<({ __typename?: 'Asset' } & AssetFragment)>, translations: Array<({ __typename?: 'ProductTranslation' } & Pick<ProductTranslation, 'id' | 'languageCode' | 'name' | 'slug' | 'description'>)>, optionGroups: Array<({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'languageCode' | 'code' | 'name'>)>, variants: Array<({ __typename?: 'ProductVariant' } & ProductVariantFragment)>, facetValues: Array<({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) })> });
+export type ProductWithVariantsFragment = ({ __typename?: 'Product' } & Pick<Product, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'languageCode' | 'name' | 'slug' | 'description'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & AssetFragment)>, assets: Array<({ __typename?: 'Asset' } & AssetFragment)>, translations: Array<({ __typename?: 'ProductTranslation' } & Pick<ProductTranslation, 'id' | 'languageCode' | 'name' | 'slug' | 'description'>)>, optionGroups: Array<({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'languageCode' | 'code' | 'name'>)>, variants: Array<({ __typename?: 'ProductVariant' } & ProductVariantFragment)>, facetValues: Array<({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'code' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'name'>) })>, channels: Array<({ __typename?: 'Channel' } & Pick<Channel, 'id' | 'code'>)> });
 
 
 export type ProductOptionGroupFragment = ({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'createdAt' | 'updatedAt' | 'languageCode' | 'code' | 'name'> & { translations: Array<({ __typename?: 'ProductOptionGroupTranslation' } & Pick<ProductOptionGroupTranslation, 'id' | 'name'>)>, options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'languageCode' | 'name' | 'code'> & { translations: Array<({ __typename?: 'ProductOptionTranslation' } & Pick<ProductOptionTranslation, 'name'>)> })> });
 export type ProductOptionGroupFragment = ({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'createdAt' | 'updatedAt' | 'languageCode' | 'code' | 'name'> & { translations: Array<({ __typename?: 'ProductOptionGroupTranslation' } & Pick<ProductOptionGroupTranslation, 'id' | 'name'>)>, options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'languageCode' | 'name' | 'code'> & { translations: Array<({ __typename?: 'ProductOptionTranslation' } & Pick<ProductOptionTranslation, 'name'>)> })> });
 
 
@@ -4031,7 +4066,7 @@ export type SearchProductsQueryVariables = {
 };
 };
 
 
 
 
-export type SearchProductsQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & Pick<SearchResponse, 'totalItems'> & { items: Array<({ __typename?: 'SearchResult' } & Pick<SearchResult, 'enabled' | 'productId' | 'productName' | 'productPreview' | 'productVariantId' | 'productVariantName' | 'productVariantPreview' | 'sku'>)>, facetValues: Array<({ __typename?: 'FacetValueResult' } & Pick<FacetValueResult, 'count'> & { facetValue: ({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'createdAt' | 'updatedAt' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'createdAt' | 'updatedAt' | 'name'>) }) })> }) });
+export type SearchProductsQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & Pick<SearchResponse, 'totalItems'> & { items: Array<({ __typename?: 'SearchResult' } & Pick<SearchResult, 'enabled' | 'productId' | 'productName' | 'productPreview' | 'productVariantId' | 'productVariantName' | 'productVariantPreview' | 'sku' | 'channelIds'>)>, facetValues: Array<({ __typename?: 'FacetValueResult' } & Pick<FacetValueResult, 'count'> & { facetValue: ({ __typename?: 'FacetValue' } & Pick<FacetValue, 'id' | 'createdAt' | 'updatedAt' | 'name'> & { facet: ({ __typename?: 'Facet' } & Pick<Facet, 'id' | 'createdAt' | 'updatedAt' | 'name'>) }) })> }) });
 
 
 export type UpdateProductOptionMutationVariables = {
 export type UpdateProductOptionMutationVariables = {
   input: UpdateProductOptionInput
   input: UpdateProductOptionInput
@@ -4054,6 +4089,13 @@ export type GetProductVariantOptionsQueryVariables = {
 
 
 export type GetProductVariantOptionsQuery = ({ __typename?: 'Query' } & { product: Maybe<({ __typename?: 'Product' } & Pick<Product, 'id' | 'createdAt' | 'updatedAt' | 'name'> & { optionGroups: Array<({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'name' | 'code'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'code'>)> })>, variants: Array<({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'name' | 'sku' | 'price' | 'stockOnHand' | 'enabled'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'code' | 'groupId'>)> })> })> });
 export type GetProductVariantOptionsQuery = ({ __typename?: 'Query' } & { product: Maybe<({ __typename?: 'Product' } & Pick<Product, 'id' | 'createdAt' | 'updatedAt' | 'name'> & { optionGroups: Array<({ __typename?: 'ProductOptionGroup' } & Pick<ProductOptionGroup, 'id' | 'name' | 'code'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'code'>)> })>, variants: Array<({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'createdAt' | 'updatedAt' | 'enabled' | 'name' | 'sku' | 'price' | 'stockOnHand' | 'enabled'> & { options: Array<({ __typename?: 'ProductOption' } & Pick<ProductOption, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'code' | 'groupId'>)> })> })> });
 
 
+export type AssignProductsToChannelMutationVariables = {
+  input: AssignProductsToChannelInput
+};
+
+
+export type AssignProductsToChannelMutation = ({ __typename?: 'Mutation' } & { assignProductsToChannel: Array<({ __typename?: 'Product' } & Pick<Product, 'id'> & { channels: Array<({ __typename?: 'Channel' } & Pick<Channel, 'id' | 'code'>)> })> });
+
 export type PromotionFragment = ({ __typename?: 'Promotion' } & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled' | 'couponCode' | 'perCustomerUsageLimit' | 'startsAt' | 'endsAt'> & { conditions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)>, actions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)> });
 export type PromotionFragment = ({ __typename?: 'Promotion' } & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled' | 'couponCode' | 'perCustomerUsageLimit' | 'startsAt' | 'endsAt'> & { conditions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)>, actions: Array<({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment)> });
 
 
 export type GetPromotionListQueryVariables = {
 export type GetPromotionListQueryVariables = {
@@ -4868,6 +4910,7 @@ export namespace ProductWithVariants {
   export type Variants = ProductVariantFragment;
   export type Variants = ProductVariantFragment;
   export type FacetValues = (NonNullable<ProductWithVariantsFragment['facetValues'][0]>);
   export type FacetValues = (NonNullable<ProductWithVariantsFragment['facetValues'][0]>);
   export type Facet = (NonNullable<ProductWithVariantsFragment['facetValues'][0]>)['facet'];
   export type Facet = (NonNullable<ProductWithVariantsFragment['facetValues'][0]>)['facet'];
+  export type Channels = (NonNullable<ProductWithVariantsFragment['channels'][0]>);
 }
 }
 
 
 export namespace ProductOptionGroup {
 export namespace ProductOptionGroup {
@@ -5007,6 +5050,13 @@ export namespace GetProductVariantOptions {
   export type _Options = (NonNullable<(NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['variants'][0]>)['options'][0]>);
   export type _Options = (NonNullable<(NonNullable<(NonNullable<GetProductVariantOptionsQuery['product']>)['variants'][0]>)['options'][0]>);
 }
 }
 
 
+export namespace AssignProductsToChannel {
+  export type Variables = AssignProductsToChannelMutationVariables;
+  export type Mutation = AssignProductsToChannelMutation;
+  export type AssignProductsToChannel = (NonNullable<AssignProductsToChannelMutation['assignProductsToChannel'][0]>);
+  export type Channels = (NonNullable<(NonNullable<AssignProductsToChannelMutation['assignProductsToChannel'][0]>)['channels'][0]>);
+}
+
 export namespace Promotion {
 export namespace Promotion {
   export type Fragment = PromotionFragment;
   export type Fragment = PromotionFragment;
   export type Conditions = ConfigurableOperationFragment;
   export type Conditions = ConfigurableOperationFragment;

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

@@ -11,7 +11,7 @@
         <div class="header-nav"></div>
         <div class="header-nav"></div>
         <div class="header-actions">
         <div class="header-actions">
             <!-- <vdr-ui-language-switcher></vdr-ui-language-switcher> -->
             <!-- <vdr-ui-language-switcher></vdr-ui-language-switcher> -->
-            <vdr-channel-switcher></vdr-channel-switcher>
+            <vdr-channel-switcher *vdrIfMultichannel></vdr-channel-switcher>
             <vdr-user-menu [userName]="userName$ | async" (logOut)="logOut()"></vdr-user-menu>
             <vdr-user-menu [userName]="userName$ | async" (logOut)="logOut()"></vdr-user-menu>
         </div>
         </div>
     </clr-header>
     </clr-header>

+ 3 - 3
packages/admin-ui/src/app/core/components/channel-switcher/channel-switcher.component.html

@@ -1,5 +1,5 @@
-<ng-container *ngIf="channels$ | async as channels">
-    <vdr-dropdown *ngIf="1 < channels.length">
+<ng-container >
+    <vdr-dropdown>
         <button class="btn btn-link active-channel" vdrDropdownTrigger>
         <button class="btn btn-link active-channel" vdrDropdownTrigger>
             <vdr-channel-badge [channelCode]="activeChannelCode$ | async"></vdr-channel-badge>
             <vdr-channel-badge [channelCode]="activeChannelCode$ | async"></vdr-channel-badge>
             <span class="active-channel">{{ activeChannelCode$ | async | channelCodeToLabel | translate }}</span>
             <span class="active-channel">{{ activeChannelCode$ | async | channelCodeToLabel | translate }}</span>
@@ -7,7 +7,7 @@
         </button>
         </button>
         <vdr-dropdown-menu vdrPosition="bottom-right">
         <vdr-dropdown-menu vdrPosition="bottom-right">
             <button
             <button
-                *ngFor="let channel of channels"
+                *ngFor="let channel of channels$ | async"
                 type="button"
                 type="button"
                 vdrDropdownItem
                 vdrDropdownItem
                 (click)="setActiveChannel(channel.id)"
                 (click)="setActiveChannel(channel.id)"

+ 17 - 0
packages/admin-ui/src/app/data/definitions/product-definitions.ts

@@ -115,6 +115,10 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
                 name
                 name
             }
             }
         }
         }
+        channels {
+            id
+            code
+        }
     }
     }
     ${PRODUCT_VARIANT_FRAGMENT}
     ${PRODUCT_VARIANT_FRAGMENT}
     ${ASSET_FRAGMENT}
     ${ASSET_FRAGMENT}
@@ -351,6 +355,7 @@ export const SEARCH_PRODUCTS = gql`
                 productVariantName
                 productVariantName
                 productVariantPreview
                 productVariantPreview
                 sku
                 sku
+                channelIds
             }
             }
             facetValues {
             facetValues {
                 count
                 count
@@ -433,3 +438,15 @@ export const GET_PRODUCT_VARIANT_OPTIONS = gql`
         }
         }
     }
     }
 `;
 `;
+
+export const ASSIGN_PRODUCTS_TO_CHANNEL = gql`
+    mutation AssignProductsToChannel($input: AssignProductsToChannelInput!) {
+        assignProductsToChannel(input: $input) {
+            id
+            channels {
+                id
+                code
+            }
+        }
+    }
+`;

+ 12 - 1
packages/admin-ui/src/app/data/providers/product-data.service.ts

@@ -3,6 +3,8 @@ import { pick } from 'shared/pick';
 import {
 import {
     AddOptionGroupToProduct,
     AddOptionGroupToProduct,
     AddOptionToGroup,
     AddOptionToGroup,
+    AssignProductsToChannel,
+    AssignProductsToChannelInput,
     CreateAssets,
     CreateAssets,
     CreateProduct,
     CreateProduct,
     CreateProductInput,
     CreateProductInput,
@@ -30,10 +32,10 @@ import {
     UpdateProductVariantInput,
     UpdateProductVariantInput,
     UpdateProductVariants,
     UpdateProductVariants,
 } from '../../common/generated-types';
 } from '../../common/generated-types';
-import { getDefaultLanguage } from '../../common/utilities/get-default-language';
 import {
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     ADD_OPTION_GROUP_TO_PRODUCT,
     ADD_OPTION_TO_GROUP,
     ADD_OPTION_TO_GROUP,
+    ASSIGN_PRODUCTS_TO_CHANNEL,
     CREATE_ASSETS,
     CREATE_ASSETS,
     CREATE_PRODUCT,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
     CREATE_PRODUCT_OPTION_GROUP,
@@ -259,4 +261,13 @@ export class ProductDataService {
             input: files.map(file => ({ file })),
             input: files.map(file => ({ file })),
         });
         });
     }
     }
+
+    assignProductsToChannel(input: AssignProductsToChannelInput) {
+        return this.baseDataService.mutate<
+            AssignProductsToChannel.Mutation,
+            AssignProductsToChannel.Variables
+        >(ASSIGN_PRODUCTS_TO_CHANNEL, {
+            input,
+        });
+    }
 }
 }

+ 1 - 0
packages/admin-ui/src/app/data/query-result.ts

@@ -49,6 +49,7 @@ export class QueryResult<T, V = Record<string, any>> {
             takeUntil(loggedOut$),
             takeUntil(loggedOut$),
             takeUntil(this.completed$),
             takeUntil(this.completed$),
         );
         );
+        this.queryRef.valueChanges = this.valueChanges;
         return this;
         return this;
     }
     }
 
 

+ 2 - 1
packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.html

@@ -4,9 +4,10 @@
     bindValue="id"
     bindValue="id"
     appendTo="body"
     appendTo="body"
     [addTag]="false"
     [addTag]="false"
-    [multiple]="true"
+    [multiple]="multiple"
     [ngModel]="value"
     [ngModel]="value"
     [clearable]="false"
     [clearable]="false"
+    [searchable]="false"
     [disabled]="disabled"
     [disabled]="disabled"
     (focus)="focussed()"
     (focus)="focussed()"
     (change)="valueChanged($event)"
     (change)="valueChanged($event)"

+ 11 - 0
packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.scss

@@ -1,10 +1,21 @@
 @import "variables";
 @import "variables";
 
 
+:host {
+    min-width: 200px;
+    &.clr-input {
+        border-bottom: none;
+        padding: 0;
+    }
+}
+
 ::ng-deep .ng-value, ::ng-deep .ng-option {
 ::ng-deep .ng-value, ::ng-deep .ng-option {
     > vdr-channel-badge {
     > vdr-channel-badge {
         margin-bottom: -1px;
         margin-bottom: -1px;
     }
     }
 }
 }
+::ng-deep .ng-value > vdr-channel-badge {
+    margin-left: 6px;
+}
 
 
 .channel-label {
 .channel-label {
     margin-right: 6px;
     margin-right: 6px;

+ 21 - 5
packages/admin-ui/src/app/shared/components/channel-assignment-control/channel-assignment-control.component.ts

@@ -1,7 +1,8 @@
-import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { Observable } from 'rxjs';
 import { Observable } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { map } from 'rxjs/operators';
+import { DEFAULT_CHANNEL_CODE } from 'shared/shared-constants';
 
 
 import { CurrentUserChannel } from '../../../common/generated-types';
 import { CurrentUserChannel } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 import { DataService } from '../../../data/providers/data.service';
@@ -20,6 +21,9 @@ import { DataService } from '../../../data/providers/data.service';
     ],
     ],
 })
 })
 export class ChannelAssignmentControlComponent implements OnInit, ControlValueAccessor {
 export class ChannelAssignmentControlComponent implements OnInit, ControlValueAccessor {
+    @Input() multiple = true;
+    @Input() includeDefaultChannel = true;
+
     channels$: Observable<CurrentUserChannel[]>;
     channels$: Observable<CurrentUserChannel[]>;
     value: string[] = [];
     value: string[] = [];
     disabled = false;
     disabled = false;
@@ -31,7 +35,13 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
     ngOnInit() {
     ngOnInit() {
         this.channels$ = this.dataService.client
         this.channels$ = this.dataService.client
             .userStatus()
             .userStatus()
-            .single$.pipe(map(data => (data.userStatus ? data.userStatus.channels : [])));
+            .single$.pipe(
+                map(({ userStatus }) =>
+                    userStatus.channels.filter(c =>
+                        this.includeDefaultChannel ? true : c.code !== DEFAULT_CHANNEL_CODE,
+                    ),
+                ),
+            );
     }
     }
 
 
     registerOnChange(fn: any): void {
     registerOnChange(fn: any): void {
@@ -53,10 +63,16 @@ export class ChannelAssignmentControlComponent implements OnInit, ControlValueAc
     }
     }
 
 
     focussed() {
     focussed() {
-        this.onTouched();
+        if (this.onTouched) {
+            this.onTouched();
+        }
     }
     }
 
 
-    valueChanged(channels: CurrentUserChannel[]) {
-        this.onChange(channels.map(c => c.id));
+    valueChanged(value: CurrentUserChannel[] | CurrentUserChannel | undefined) {
+        if (Array.isArray(value)) {
+            this.onChange(value.map(c => c.id));
+        } else {
+            this.onChange([value ? value.id : undefined]);
+        }
     }
     }
 }
 }

+ 1 - 3
packages/admin-ui/src/app/shared/components/channel-badge/channel-badge.component.html

@@ -1,3 +1 @@
-<div class="channel-badge"
-    [style.background-color]="(isDefaultChannel ? '' : channelCode) | stringToColor"
-></div>
+<clr-icon shape="layers" [style.color]="isDefaultChannel ? '#aaa' : (channelCode | stringToColor)"></clr-icon>

+ 2 - 6
packages/admin-ui/src/app/shared/components/channel-badge/channel-badge.component.scss

@@ -8,10 +8,6 @@
     }
     }
 }
 }
 
 
-.channel-badge {
-    width: 8px;
-    height: 12px;
-    border-radius: 1px;
-    margin: 0 6px;
-    border: 1px solid transparentize($color-grey-700, 0.8);
+clr-icon {
+    margin-right: 6px;
 }
 }

+ 1 - 1
packages/admin-ui/src/app/shared/components/form-item/form-item.component.html

@@ -1,4 +1,4 @@
 <div class="form-group">
 <div class="form-group">
-    <label>{{ label }}</label>
+    <label class="clr-control-label">{{ label }}</label>
     <div class="content"><ng-content></ng-content></div>
     <div class="content"><ng-content></ng-content></div>
 </div>
 </div>

+ 75 - 0
packages/admin-ui/src/app/shared/directives/if-directive-base.ts

@@ -0,0 +1,75 @@
+import { EmbeddedViewRef, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
+import { Permission } from '@vendure/common/lib/generated-types';
+import { BehaviorSubject, combineLatest, Observable, Subject, Subscription } from 'rxjs';
+import { switchMap, take } from 'rxjs/operators';
+
+/**
+ * A base class for implementing custom *ngIf-style structural directives based on custom conditions.
+ */
+export class IfDirectiveBase<Args extends any[]> implements OnInit, OnDestroy {
+    protected updateArgs$ = new BehaviorSubject<Args>([] as any);
+    private readonly _thenTemplateRef: TemplateRef<any> | null = null;
+    private _elseTemplateRef: TemplateRef<any> | null = null;
+    private _thenViewRef: EmbeddedViewRef<any> | null = null;
+    private _elseViewRef: EmbeddedViewRef<any> | null = null;
+    private subscription: Subscription;
+
+    constructor(
+        private _viewContainer: ViewContainerRef,
+        templateRef: TemplateRef<any>,
+        private updateViewFn: (...args: Args) => Observable<boolean>,
+    ) {
+        this._thenTemplateRef = templateRef;
+    }
+
+    ngOnInit(): void {
+        this.subscription = this.updateArgs$
+            .pipe(switchMap(args => this.updateViewFn(...args)))
+            .subscribe(result => {
+                if (result) {
+                    this.showThen();
+                } else {
+                    this.showElse();
+                }
+            });
+    }
+
+    ngOnDestroy(): void {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+
+    protected setElseTemplate(templateRef: TemplateRef<any> | null) {
+        this.assertTemplate('vdrIfPermissionsElse', templateRef);
+        this._elseTemplateRef = templateRef;
+        this._elseViewRef = null; // clear previous view if any.
+    }
+
+    private showThen() {
+        if (!this._thenViewRef) {
+            this._viewContainer.clear();
+            this._elseViewRef = null;
+            if (this._thenTemplateRef) {
+                this._thenViewRef = this._viewContainer.createEmbeddedView(this._thenTemplateRef);
+            }
+        }
+    }
+
+    private showElse() {
+        if (!this._elseViewRef) {
+            this._viewContainer.clear();
+            this._thenViewRef = null;
+            if (this._elseTemplateRef) {
+                this._elseViewRef = this._viewContainer.createEmbeddedView(this._elseTemplateRef);
+            }
+        }
+    }
+
+    private assertTemplate(property: string, templateRef: TemplateRef<any> | null): void {
+        const isTemplateRefOrNull = !!(!templateRef || templateRef.createEmbeddedView);
+        if (!isTemplateRefOrNull) {
+            throw new Error(`${property} must be a TemplateRef, but received '${templateRef}'.`);
+        }
+    }
+}

+ 119 - 0
packages/admin-ui/src/app/shared/directives/if-multichannel.directive.spec.ts

@@ -0,0 +1,119 @@
+/* tslint:disable:component-class-suffix */
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BehaviorSubject } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { DataService } from '../../data/providers/data.service';
+
+import { IfMultichannelDirective } from './if-multichannel.directive';
+
+describe('vdrIfMultichannel directive', () => {
+    describe('simple usage', () => {
+        let fixture: ComponentFixture<TestComponentSimple>;
+
+        beforeEach(() => {
+            fixture = TestBed.configureTestingModule({
+                declarations: [TestComponentSimple, IfMultichannelDirective],
+                providers: [{ provide: DataService, useClass: MockDataService }],
+            }).createComponent(TestComponentSimple);
+            fixture.detectChanges(); // initial binding
+        });
+
+        it('is multichannel', () => {
+            ((fixture.componentInstance.dataService as unknown) as MockDataService).setChannels([1, 2]);
+            fixture.detectChanges();
+
+            const thenEl = fixture.nativeElement.querySelector('.then');
+            expect(thenEl).not.toBeNull();
+        });
+
+        it('not multichannel', () => {
+            ((fixture.componentInstance.dataService as unknown) as MockDataService).setChannels([1]);
+            fixture.detectChanges();
+
+            const thenEl = fixture.nativeElement.querySelector('.then');
+            expect(thenEl).toBeNull();
+        });
+    });
+
+    describe('if-else usage', () => {
+        let fixture: ComponentFixture<TestComponentIfElse>;
+
+        beforeEach(() => {
+            fixture = TestBed.configureTestingModule({
+                declarations: [TestComponentIfElse, IfMultichannelDirective],
+                providers: [{ provide: DataService, useClass: MockDataService }],
+            }).createComponent(TestComponentIfElse);
+            fixture.detectChanges(); // initial binding
+        });
+
+        it('is multichannel', () => {
+            ((fixture.componentInstance.dataService as unknown) as MockDataService).setChannels([1, 2]);
+            fixture.detectChanges();
+
+            const thenEl = fixture.nativeElement.querySelector('.then');
+            expect(thenEl).not.toBeNull();
+            const elseEl = fixture.nativeElement.querySelector('.else');
+            expect(elseEl).toBeNull();
+        });
+
+        it('not multichannel', () => {
+            ((fixture.componentInstance.dataService as unknown) as MockDataService).setChannels([1]);
+            fixture.detectChanges();
+
+            const thenEl = fixture.nativeElement.querySelector('.then');
+            expect(thenEl).toBeNull();
+            const elseEl = fixture.nativeElement.querySelector('.else');
+            expect(elseEl).not.toBeNull();
+        });
+    });
+});
+
+@Component({
+    template: `
+        <div *vdrIfMultichannel>
+            <span class="then"></span>
+        </div>
+    `,
+})
+export class TestComponentSimple {
+    constructor(public dataService: DataService) {}
+    @Input() permissionToTest = '';
+}
+
+@Component({
+    template: `
+        <ng-template vdrIfMultichannel [vdrIfMultichannelElse]="not">
+            <span class="then"></span>
+        </ng-template>
+        <ng-template #not><span class="else"></span></ng-template>
+    `,
+})
+export class TestComponentIfElse {
+    constructor(public dataService: DataService) {}
+    @Input() permissionToTest = '';
+}
+
+class MockDataService {
+    private channels$ = new BehaviorSubject<any[]>([]);
+    setChannels(channels: any[]) {
+        this.channels$.next(channels);
+    }
+    client = {
+        userStatus: () => {
+            return {
+                mapStream: (mapFn: any) =>
+                    this.channels$.pipe(
+                        map(channels =>
+                            mapFn({
+                                userStatus: {
+                                    channels,
+                                },
+                            }),
+                        ),
+                    ),
+            };
+        },
+    };
+}

+ 30 - 0
packages/admin-ui/src/app/shared/directives/if-multichannel.directive.ts

@@ -0,0 +1,30 @@
+import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
+
+import { DataService } from '../../data/providers/data.service';
+
+import { IfDirectiveBase } from './if-directive-base';
+
+@Directive({
+    selector: '[vdrIfMultichannel]',
+})
+export class IfMultichannelDirective extends IfDirectiveBase<[]> {
+    constructor(
+        _viewContainer: ViewContainerRef,
+        templateRef: TemplateRef<any>,
+        private dataService: DataService,
+    ) {
+        super(_viewContainer, templateRef, () => {
+            return this.dataService.client
+                .userStatus()
+                .mapStream(({ userStatus }) => 1 < userStatus.channels.length);
+        });
+    }
+
+    /**
+     * A template to show if the current user does not have the speicified permission.
+     */
+    @Input()
+    set vdrIfMultichannelElse(templateRef: TemplateRef<any> | null) {
+        this.setElseTemplate(templateRef);
+    }
+}

+ 68 - 0
packages/admin-ui/src/app/shared/directives/if-permissions.directive.spec.ts

@@ -0,0 +1,68 @@
+import { Component, Input } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { DataService } from '../../data/providers/data.service';
+
+import { IfPermissionsDirective } from './if-permissions.directive';
+
+describe('vdrIfPermissions directive', () => {
+    let fixture: ComponentFixture<TestComponent>;
+
+    beforeEach(() => {
+        fixture = TestBed.configureTestingModule({
+            declarations: [TestComponent, IfPermissionsDirective],
+            providers: [{ provide: DataService, useClass: MockDataService }],
+        }).createComponent(TestComponent);
+        fixture.detectChanges(); // initial binding
+    });
+
+    it('has permission', () => {
+        fixture.componentInstance.permissionToTest = 'ValidPermission';
+        fixture.detectChanges();
+
+        const thenEl = fixture.nativeElement.querySelector('.then');
+        expect(thenEl).not.toBeNull();
+        const elseEl = fixture.nativeElement.querySelector('.else');
+        expect(elseEl).toBeNull();
+    });
+
+    it('does not have permission', () => {
+        fixture.componentInstance.permissionToTest = 'InvalidPermission';
+        fixture.detectChanges();
+
+        const thenEl = fixture.nativeElement.querySelector('.then');
+        expect(thenEl).toBeNull();
+        const elseEl = fixture.nativeElement.querySelector('.else');
+        expect(elseEl).not.toBeNull();
+    });
+});
+
+@Component({
+    template: `
+        <div *vdrIfPermissions="permissionToTest; else noPerms">
+            <span class="then"></span>
+        </div>
+        <ng-template #noPerms><span class="else"></span></ng-template>
+    `,
+})
+export class TestComponent {
+    @Input() permissionToTest = '';
+}
+
+class MockDataService {
+    client = {
+        userStatus() {
+            return {
+                mapSingle: (mapFn: any) =>
+                    of(
+                        mapFn({
+                            userStatus: {
+                                permissions: ['ValidPermission'],
+                            },
+                        }),
+                    ),
+            };
+        },
+    };
+}

+ 17 - 54
packages/admin-ui/src/app/shared/directives/if-permissions.directive.ts

@@ -1,7 +1,10 @@
 import { Directive, EmbeddedViewRef, Input, TemplateRef, ViewContainerRef } from '@angular/core';
 import { Directive, EmbeddedViewRef, Input, TemplateRef, ViewContainerRef } from '@angular/core';
-import { DataService } from '@vendure/admin-ui/src/app/data/providers/data.service';
+import { of } from 'rxjs';
 
 
 import { Permission } from '../../common/generated-types';
 import { Permission } from '../../common/generated-types';
+import { DataService } from '../../data/providers/data.service';
+
+import { IfDirectiveBase } from './if-directive-base';
 
 
 /**
 /**
  * Conditionally shows/hides templates based on the current active user having the specified permission.
  * Conditionally shows/hides templates based on the current active user having the specified permission.
@@ -16,19 +19,22 @@ import { Permission } from '../../common/generated-types';
 @Directive({
 @Directive({
     selector: '[vdrIfPermissions]',
     selector: '[vdrIfPermissions]',
 })
 })
-export class IfPermissionsDirective {
-    private readonly _thenTemplateRef: TemplateRef<any> | null = null;
-    private _elseTemplateRef: TemplateRef<any> | null = null;
-    private _thenViewRef: EmbeddedViewRef<any> | null = null;
-    private _elseViewRef: EmbeddedViewRef<any> | null = null;
+export class IfPermissionsDirective extends IfDirectiveBase<[Permission | null]> {
     private permissionToCheck: string | null = '__initial_value__';
     private permissionToCheck: string | null = '__initial_value__';
 
 
     constructor(
     constructor(
-        private _viewContainer: ViewContainerRef,
+        _viewContainer: ViewContainerRef,
         templateRef: TemplateRef<any>,
         templateRef: TemplateRef<any>,
         private dataService: DataService,
         private dataService: DataService,
     ) {
     ) {
-        this._thenTemplateRef = templateRef;
+        super(_viewContainer, templateRef, permission => {
+            if (!permission) {
+                return of(false);
+            }
+            return this.dataService.client
+                .userStatus()
+                .mapSingle(({ userStatus }) => userStatus.permissions.includes(permission));
+        });
     }
     }
 
 
     /**
     /**
@@ -37,7 +43,7 @@ export class IfPermissionsDirective {
     @Input()
     @Input()
     set vdrIfPermissions(permission: string | null) {
     set vdrIfPermissions(permission: string | null) {
         this.permissionToCheck = permission;
         this.permissionToCheck = permission;
-        this._updateView(permission as Permission);
+        this.updateArgs$.next([permission as Permission]);
     }
     }
 
 
     /**
     /**
@@ -45,50 +51,7 @@ export class IfPermissionsDirective {
      */
      */
     @Input()
     @Input()
     set vdrIfPermissionsElse(templateRef: TemplateRef<any> | null) {
     set vdrIfPermissionsElse(templateRef: TemplateRef<any> | null) {
-        assertTemplate('vdrIfPermissionsElse', templateRef);
-        this._elseTemplateRef = templateRef;
-        this._elseViewRef = null; // clear previous view if any.
-        this._updateView(this.permissionToCheck as Permission);
-    }
-
-    private _updateView(permission: Permission | null) {
-        if (!permission) {
-            this.showThen();
-            return;
-        }
-        this.dataService.client.userStatus().single$.subscribe(({ userStatus }) => {
-            if (userStatus.permissions.includes(permission)) {
-                this.showThen();
-            } else {
-                this.showElse();
-            }
-        });
-    }
-
-    private showThen() {
-        if (!this._thenViewRef) {
-            this._viewContainer.clear();
-            this._elseViewRef = null;
-            if (this._thenTemplateRef) {
-                this._thenViewRef = this._viewContainer.createEmbeddedView(this._thenTemplateRef);
-            }
-        }
-    }
-
-    private showElse() {
-        if (!this._elseViewRef) {
-            this._viewContainer.clear();
-            this._thenViewRef = null;
-            if (this._elseTemplateRef) {
-                this._elseViewRef = this._viewContainer.createEmbeddedView(this._elseTemplateRef);
-            }
-        }
-    }
-}
-
-function assertTemplate(property: string, templateRef: TemplateRef<any> | null): void {
-    const isTemplateRefOrNull = !!(!templateRef || templateRef.createEmbeddedView);
-    if (!isTemplateRefOrNull) {
-        throw new Error(`${property} must be a TemplateRef, but received '${templateRef}'.`);
+        this.setElseTemplate(templateRef);
+        this.updateArgs$.next([this.permissionToCheck as Permission]);
     }
     }
 }
 }

+ 2 - 0
packages/admin-ui/src/app/shared/shared-declarations.ts

@@ -34,6 +34,8 @@ export { FormFieldControlDirective } from './components/form-field/form-field-co
 export { FormFieldComponent } from './components/form-field/form-field.component';
 export { FormFieldComponent } from './components/form-field/form-field.component';
 export { FormItemComponent } from './components/form-item/form-item.component';
 export { FormItemComponent } from './components/form-item/form-item.component';
 export { FormattedAddressComponent } from './components/formatted-address/formatted-address.component';
 export { FormattedAddressComponent } from './components/formatted-address/formatted-address.component';
+export { IfMultichannelDirective } from './directives/if-multichannel.directive';
+export { IfPermissionsDirective } from './directives/if-permissions.directive';
 export {
 export {
     ItemsPerPageControlsComponent,
     ItemsPerPageControlsComponent,
 } from './components/items-per-page-controls/items-per-page-controls.component';
 } from './components/items-per-page-controls/items-per-page-controls.component';

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

@@ -15,7 +15,6 @@ import {
     ActionBarRightComponent,
     ActionBarRightComponent,
 } from './components/action-bar/action-bar.component';
 } from './components/action-bar/action-bar.component';
 import { DisabledDirective } from './directives/disabled.directive';
 import { DisabledDirective } from './directives/disabled.directive';
-import { IfPermissionsDirective } from './directives/if-permissions.directive';
 import { HasPermissionPipe } from './pipes/has-permission.pipe';
 import { HasPermissionPipe } from './pipes/has-permission.pipe';
 import { ModalService } from './providers/modal/modal.service';
 import { ModalService } from './providers/modal/modal.service';
 import { CanDeactivateDetailGuard } from './providers/routing/can-deactivate-detail-guard';
 import { CanDeactivateDetailGuard } from './providers/routing/can-deactivate-detail-guard';
@@ -51,6 +50,8 @@ import {
     FormFieldComponent,
     FormFieldComponent,
     FormFieldControlDirective,
     FormFieldControlDirective,
     FormItemComponent,
     FormItemComponent,
+    IfMultichannelDirective,
+    IfPermissionsDirective,
     ItemsPerPageControlsComponent,
     ItemsPerPageControlsComponent,
     LabeledDataComponent,
     LabeledDataComponent,
     LanguageSelectorComponent,
     LanguageSelectorComponent,
@@ -125,6 +126,7 @@ const DECLARATIONS = [
     StringToColorPipe,
     StringToColorPipe,
     ObjectTreeComponent,
     ObjectTreeComponent,
     IfPermissionsDirective,
     IfPermissionsDirective,
+    IfMultichannelDirective,
     HasPermissionPipe,
     HasPermissionPipe,
     ActionBarItemsComponent,
     ActionBarItemsComponent,
     DisabledDirective,
     DisabledDirective,

+ 10 - 0
packages/admin-ui/src/i18n-messages/en.json

@@ -29,6 +29,11 @@
     "add-facets": "Add facets",
     "add-facets": "Add facets",
     "add-option": "Add option",
     "add-option": "Add option",
     "assets-selected-count": "{ count } assets selected",
     "assets-selected-count": "{ count } assets selected",
+    "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 }",
+    "channel-price-preview": "Channel price preview",
     "collection-contents": "Collection contents",
     "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?",
     "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?",
     "confirm-adding-options-delete-default-title": "Delete default variant?",
     "confirm-adding-options-delete-default-title": "Delete default variant?",
@@ -60,6 +65,7 @@
     "move-down": "Move down",
     "move-down": "Move down",
     "move-to": "Move to",
     "move-to": "Move to",
     "move-up": "Move up",
     "move-up": "Move up",
+    "no-channel-selected": "No channel selected",
     "no-featured-asset": "No featured asset",
     "no-featured-asset": "No featured asset",
     "no-selection": "No selection",
     "no-selection": "No selection",
     "notify-create-assets-success": "Created {count, plural, one {new Asset} other {{count} new Assets}}",
     "notify-create-assets-success": "Created {count, plural, one {new Asset} other {{count} new Assets}}",
@@ -71,6 +77,8 @@
     "preview": "Preview",
     "preview": "Preview",
     "preview-size": "Preview size",
     "preview-size": "Preview size",
     "price": "Price",
     "price": "Price",
+    "price-conversion-factor": "Price conversion factor",
+    "price-in-channel": "Price in { channel }",
     "price-includes-tax-at": "Includes tax at { rate }%",
     "price-includes-tax-at": "Includes tax at { rate }%",
     "price-with-tax-in-default-zone": "Inc. { rate }% tax: { price }",
     "price-with-tax-in-default-zone": "Inc. { rate }% tax: { price }",
     "private": "Private",
     "private": "Private",
@@ -111,6 +119,8 @@
     "available-languages": "Available languages",
     "available-languages": "Available languages",
     "cancel": "Cancel",
     "cancel": "Cancel",
     "cancel-navigation": "Cancel navigation",
     "cancel-navigation": "Cancel navigation",
+    "channel": "Channel",
+    "channels": "Channels",
     "code": "Code",
     "code": "Code",
     "confirm-navigation": "Confirm navigation",
     "confirm-navigation": "Confirm navigation",
     "create": "Create",
     "create": "Create",