Browse Source

feat(admin-ui): Add support for bulk product channel assignment

Relates to #853
Michael Bromley 3 years ago
parent
commit
6ee74e423d

+ 5 - 1
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -19,7 +19,10 @@ import { FacetListComponent } from './components/facet-list/facet-list.component
 import { GenerateProductVariantsComponent } from './components/generate-product-variants/generate-product-variants.component';
 import { OptionValueInputComponent } from './components/option-value-input/option-value-input.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
-import { deleteProductsBulkAction } from './components/product-list/product-list-bulk-actions';
+import {
+    assignProductsToChannelBulkAction,
+    deleteProductsBulkAction,
+} from './components/product-list/product-list-bulk-actions';
 import { ProductListComponent } from './components/product-list/product-list.component';
 import { ProductOptionsEditorComponent } from './components/product-options-editor/product-options-editor.component';
 import { ProductVariantsEditorComponent } from './components/product-variants-editor/product-variants-editor.component';
@@ -61,6 +64,7 @@ const CATALOG_COMPONENTS = [
 })
 export class CatalogModule {
     constructor(private bulkActionRegistryService: BulkActionRegistryService) {
+        bulkActionRegistryService.registerBulkAction(assignProductsToChannelBulkAction);
         bulkActionRegistryService.registerBulkAction(deleteProductsBulkAction);
     }
 }

+ 37 - 1
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts

@@ -7,9 +7,12 @@ import {
     NotificationService,
     SearchProducts,
 } from '@vendure/admin-ui/core';
+import { unique } from '@vendure/common/lib/unique';
 import { EMPTY } from 'rxjs';
 import { switchMap } from 'rxjs/operators';
 
+import { AssignProductsToChannelDialogComponent } from '../assign-products-to-channel-dialog/assign-products-to-channel-dialog.component';
+
 import { ProductListComponent } from './product-list.component';
 
 export const deleteProductsBulkAction: BulkAction<SearchProducts.Items, ProductListComponent> = {
@@ -34,7 +37,9 @@ export const deleteProductsBulkAction: BulkAction<SearchProducts.Items, ProductL
             })
             .pipe(
                 switchMap(response =>
-                    response ? dataService.product.deleteProducts(selection.map(p => p.productId)) : EMPTY,
+                    response
+                        ? dataService.product.deleteProducts(unique(selection.map(p => p.productId)))
+                        : EMPTY,
                 ),
             )
             .subscribe(result => {
@@ -61,3 +66,34 @@ export const deleteProductsBulkAction: BulkAction<SearchProducts.Items, ProductL
             });
     },
 };
+
+export const assignProductsToChannelBulkAction: BulkAction<SearchProducts.Items, ProductListComponent> = {
+    location: 'product-list',
+    label: _('catalog.assign-to-channel'),
+    icon: 'layers',
+    isVisible: ({ injector }) => {
+        return injector
+            .get(DataService)
+            .client.userStatus()
+            .mapSingle(({ userStatus }) => 1 < userStatus.channels.length)
+            .toPromise();
+    },
+    onClick: ({ injector, selection, hostComponent, clearSelection }) => {
+        const modalService = injector.get(ModalService);
+        const dataService = injector.get(DataService);
+        const notificationService = injector.get(NotificationService);
+        modalService
+            .fromComponent(AssignProductsToChannelDialogComponent, {
+                size: 'lg',
+                locals: {
+                    productIds: unique(selection.map(p => p.productId)),
+                    currentChannelIds: [],
+                },
+            })
+            .subscribe(result => {
+                if (result) {
+                    clearSelection();
+                }
+            });
+    },
+};

+ 53 - 0
packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/bulk-action-types.ts

@@ -1,5 +1,6 @@
 import { Injector } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
+
 /**
  * @description
  * A valid location of a list view that supports the bulk actions API.
@@ -10,6 +11,29 @@ import { ActivatedRoute } from '@angular/router';
  */
 export type BulkActionLocationId = 'product-list' | 'order-list' | string;
 
+export interface BulkActionIsVisibleContext<ItemType, ComponentType> {
+    /**
+     * @description
+     * An array of the selected items from the list.
+     */
+    selection: ItemType[];
+    /**
+     * @description
+     * The component instance that is hosting the list view. For instance,
+     * `ProductListComponent`. This can be used to call methods on the instance,
+     * e.g. calling `hostComponent.refresh()` to force a list refresh after
+     * deleting the selected items.
+     */
+    hostComponent: ComponentType;
+    /**
+     * @description
+     * The Angular [Injector](https://angular.io/api/core/Injector) which can be used
+     * to get service instances which might be needed in the click handler.
+     */
+    injector: Injector;
+    route: ActivatedRoute;
+}
+
 /**
  * @description
  * This is the argument which gets passed to the `onClick` function of a BulkAction.
@@ -61,10 +85,39 @@ export interface BulkActionClickContext<ItemType, ComponentType> {
 export interface BulkAction<ItemType = any, ComponentType = any> {
     location: BulkActionLocationId;
     label: string;
+    /**
+     * @description
+     * A valid [Clarity Icons](https://clarity.design/icons) icon shape, e.g.
+     * "cog", "user", "info-standard".
+     */
     icon?: string;
+    /**
+     * @description
+     * A class to be added to the icon element. Examples:
+     *
+     * - is-success
+     * - is-danger
+     * - is-warning
+     * - is-info
+     * - is-highlight
+     */
     iconClass?: string;
+    /**
+     * @description
+     * Defines the logic that executes when the bulk action button is clicked.
+     */
     onClick: (context: BulkActionClickContext<ItemType, ComponentType>) => void;
     /**
+     * @description
+     * A function that determines whether this bulk action item should be displayed in the menu.
+     * If not defined, the item will always be displayed.
+     *
+     * This function will be invoked each time the selection is changed, so try to avoid expensive code
+     * running here.
+     */
+    isVisible?: (context: BulkActionIsVisibleContext<ItemType, ComponentType>) => boolean | Promise<boolean>;
+    /**
+     * @description
      * Control the display of this item based on the user permissions.
      */
     requiresPermission?: string | ((userPermissions: string[]) => boolean);

+ 1 - 0
packages/admin-ui/src/lib/core/src/providers/nav-builder/nav-builder-types.ts

@@ -58,6 +58,7 @@ export interface NavMenuSection {
     label: string;
     items: NavMenuItem[];
     /**
+     * @description
      * Control the display of this item based on the user permissions.
      */
     requiresPermission?: string | ((userPermissions: string[]) => boolean);

+ 18 - 11
packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.html

@@ -1,17 +1,24 @@
-<vdr-dropdown *ngIf="actions.length">
-    <button class="btn btn-sm btn-outline" vdrDropdownTrigger [disabled]="!(selection?.length)">
+<vdr-dropdown *ngIf="actions$ | async as actions">
+    <button class="btn btn-sm btn-outline" vdrDropdownTrigger [disabled]="!selection?.length">
         <clr-icon shape="file-group"></clr-icon>
         {{ 'common.with-selected' | translate }}
     </button>
     <vdr-dropdown-menu vdrPosition="bottom-left">
-        <button
-            *ngFor="let action of actions"
-            type="button"
-            vdrDropdownItem
-            (click)="actionClick($event, action)"
-        >
-            <clr-icon *ngIf="action.icon" [attr.shape]="action.icon" [ngClass]="action.iconClass || ''"></clr-icon>
-            {{ action.label | translate }}
-        </button>
+        <ng-container *ngFor="let action of actions">
+            <button
+                *ngIf="action.display"
+                [disabled]="!hasPermissions(action)"
+                type="button"
+                vdrDropdownItem
+                (click)="actionClick($event, action)"
+            >
+                <clr-icon
+                    *ngIf="action.icon"
+                    [attr.shape]="action.icon"
+                    [ngClass]="action.iconClass || ''"
+                ></clr-icon>
+                {{ action.label | translate }}
+            </button>
+        </ng-container>
     </vdr-dropdown-menu>
 </vdr-dropdown>

+ 64 - 3
packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.ts

@@ -4,11 +4,17 @@ import {
     EventEmitter,
     Injector,
     Input,
+    OnChanges,
+    OnDestroy,
     OnInit,
     Output,
+    SimpleChanges,
 } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
 
+import { DataService } from '../../../data/providers/data.service';
 import { BulkActionRegistryService } from '../../../providers/bulk-action-registry/bulk-action-registry.service';
 import { BulkAction, BulkActionLocationId } from '../../../providers/bulk-action-registry/bulk-action-types';
 
@@ -18,21 +24,76 @@ import { BulkAction, BulkActionLocationId } from '../../../providers/bulk-action
     styleUrls: ['./bulk-action-menu.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class BulkActionMenuComponent<T = any> implements OnInit {
+export class BulkActionMenuComponent<T = any> implements OnInit, OnChanges, OnDestroy {
     @Input() locationId: BulkActionLocationId;
     @Input() selection: T[];
     @Input() hostComponent: any;
     @Output() clearSelection = new EventEmitter<void>();
-    actions: Array<BulkAction<T>>;
+    actions$: Observable<Array<BulkAction<T> & { display: boolean }>>;
+    userPermissions: string[] = [];
+    selection$ = new BehaviorSubject<T[]>([]);
+
+    private subscription: Subscription;
 
     constructor(
         private bulkActionRegistryService: BulkActionRegistryService,
         private injector: Injector,
         private route: ActivatedRoute,
+        private dataService: DataService,
     ) {}
 
+    ngOnChanges(changes: SimpleChanges) {
+        if ('selection' in changes) {
+            this.selection$.next(this.selection);
+        }
+    }
+
     ngOnInit(): void {
-        this.actions = this.bulkActionRegistryService.getBulkActionsForLocation(this.locationId);
+        const actionsForLocation = this.bulkActionRegistryService.getBulkActionsForLocation(this.locationId);
+        this.actions$ = this.selection$.pipe(
+            switchMap(selection => {
+                return Promise.all(
+                    actionsForLocation.map(async action => {
+                        let display = true;
+                        const isVisibleFn = action.isVisible;
+                        if (typeof isVisibleFn === 'function') {
+                            display = await isVisibleFn({
+                                injector: this.injector,
+                                hostComponent: this.hostComponent,
+                                route: this.route,
+                                selection,
+                            });
+                        }
+                        return { ...action, display };
+                    }),
+                );
+            }),
+        );
+        this.subscription = this.dataService.client
+            .userStatus()
+            .mapStream(({ userStatus }) => {
+                this.userPermissions = userStatus.permissions;
+            })
+            .subscribe();
+    }
+
+    ngOnDestroy() {
+        this.subscription?.unsubscribe();
+    }
+
+    hasPermissions(bulkAction: Pick<BulkAction, 'requiresPermission'>) {
+        if (!this.userPermissions) {
+            return false;
+        }
+        if (!bulkAction.requiresPermission) {
+            return true;
+        }
+        if (typeof bulkAction.requiresPermission === 'string') {
+            return this.userPermissions.includes(bulkAction.requiresPermission);
+        }
+        if (typeof bulkAction.requiresPermission === 'function') {
+            return bulkAction.requiresPermission(this.userPermissions);
+        }
     }
 
     actionClick(event: MouseEvent, action: BulkAction) {