Selaa lähdekoodia

feat(admin-ui): Create supporting infrastructure for bulk actions API

Michael Bromley 3 vuotta sitten
vanhempi
sitoutus
7b8d072f89
16 muutettua tiedostoa jossa 290 lisäystä ja 15 poistoa
  1. 7 2
      packages/admin-ui/src/lib/catalog/src/catalog.module.ts
  2. 33 0
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list-bulk-actions.ts
  3. 10 0
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.html
  4. 31 12
      packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts
  5. 1 0
      packages/admin-ui/src/lib/catalog/src/public_api.ts
  6. 4 0
      packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts
  7. 24 0
      packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/bulk-action-registry.service.ts
  8. 50 0
      packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/bulk-action-types.ts
  9. 60 0
      packages/admin-ui/src/lib/core/src/providers/bulk-action-registry/register-bulk-action.ts
  10. 4 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  11. 17 0
      packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.html
  12. 0 0
      packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.scss
  13. 36 0
      packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.ts
  14. 10 0
      packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.scss
  15. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  16. 1 1
      packages/admin-ui/src/lib/static/styles/global/_overrides.scss

+ 7 - 2
packages/admin-ui/src/lib/catalog/src/catalog.module.ts

@@ -1,6 +1,6 @@
 import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
-import { SharedModule } from '@vendure/admin-ui/core';
+import { BulkActionRegistryService, SharedModule } from '@vendure/admin-ui/core';
 
 import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
@@ -19,6 +19,7 @@ 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 { 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';
@@ -58,4 +59,8 @@ const CATALOG_COMPONENTS = [
     exports: [...CATALOG_COMPONENTS],
     declarations: [...CATALOG_COMPONENTS],
 })
-export class CatalogModule {}
+export class CatalogModule {
+    constructor(private bulkActionRegistryService: BulkActionRegistryService) {
+        bulkActionRegistryService.registerBulkAction(deleteProductsBulkAction);
+    }
+}

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

@@ -0,0 +1,33 @@
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import { BulkAction, DataService, ModalService } from '@vendure/admin-ui/core';
+import { delay } from 'rxjs/operators';
+
+export const deleteProductsBulkAction: BulkAction = {
+    location: 'product-list',
+    label: _('common.delete'),
+    icon: 'trash',
+    iconClass: 'is-danger',
+    onClick: ({ injector, selection }) => {
+        const modalService = injector.get(ModalService);
+        const dataService = injector.get(DataService);
+        modalService
+            .dialog({
+                title: _('catalog.confirm-bulk-delete-products'),
+                translationVars: {
+                    count: selection.length,
+                },
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('common.delete'), returnValue: true },
+                ],
+            })
+            .pipe
+            // switchMap(response =>
+            //     response ? dataService.product.deleteProduct(productId) : EMPTY,
+            // ),
+            // Short delay to allow the product to be removed from the search index before
+            // refreshing.
+            ()
+            .subscribe();
+    },
+};

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

@@ -83,7 +83,17 @@
     [currentPage]="currentPage$ | async"
     (pageChange)="setPageNumber($event)"
     (itemsPerPageChange)="setItemsPerPage($event)"
+    [allSelected]="areAllSelected()"
+    [isRowSelectedFn]="isMemberSelected"
+    (rowSelectChange)="toggleSelectMember($event)"
+    (allSelectChange)="toggleSelectAll()"
 >
+    <vdr-dt-column>
+        <vdr-bulk-action-menu locationId="product-list" [selection]="selectionManager.selection"></vdr-bulk-action-menu>
+    </vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
     <ng-template let-result="item">
         <td class="left align-middle image-col" [class.disabled]="!result.enabled">
             <div class="image-placeholder">

+ 31 - 12
packages/admin-ui/src/lib/catalog/src/components/product-list/product-list.component.ts

@@ -13,20 +13,11 @@ import {
     ProductSearchInputComponent,
     SearchInput,
     SearchProducts,
+    SelectionManager,
     ServerConfigService,
 } from '@vendure/admin-ui/core';
-import { EMPTY, Observable, of } from 'rxjs';
-import {
-    delay,
-    distinctUntilChanged,
-    map,
-    shareReplay,
-    switchMap,
-    take,
-    takeUntil,
-    tap,
-    withLatestFrom,
-} from 'rxjs/operators';
+import { EMPTY, Observable } from 'rxjs';
+import { delay, map, switchMap, take, takeUntil, tap, withLatestFrom } from 'rxjs/operators';
 
 @Component({
     selector: 'vdr-products-list',
@@ -45,9 +36,11 @@ export class ProductListComponent
     availableLanguages$: Observable<LanguageCode[]>;
     contentLanguage$: Observable<LanguageCode>;
     pendingSearchIndexUpdates = 0;
+    selectionManager: SelectionManager<SearchProducts.Items>;
 
     @ViewChild('productSearchInputComponent', { static: true })
     private productSearchInput: ProductSearchInputComponent;
+
     constructor(
         private dataService: DataService,
         private modalService: ModalService,
@@ -93,6 +86,12 @@ export class ProductListComponent
                 } as SearchInput,
             }),
         );
+        this.selectionManager = new SelectionManager<SearchProducts.Items>({
+            multiSelect: true,
+            itemsAreEqual: (a, b) =>
+                this.groupByProduct ? a.productId === b.productId : a.productVariantId === b.productVariantId,
+            additiveMode: true,
+        });
     }
 
     ngOnInit() {
@@ -115,6 +114,10 @@ export class ProductListComponent
             .getPendingSearchIndexUpdates()
             .mapSingle(({ pendingSearchIndexUpdates }) => pendingSearchIndexUpdates)
             .subscribe(value => (this.pendingSearchIndexUpdates = value));
+
+        this.items$
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(items => this.selectionManager.setCurrentItems(items));
     }
 
     ngAfterViewInit() {
@@ -195,4 +198,20 @@ export class ProductListComponent
     setLanguage(code: LanguageCode) {
         this.dataService.client.setContentLanguage(code).subscribe();
     }
+
+    areAllSelected(): boolean {
+        return this.selectionManager.areAllCurrentItemsSelected();
+    }
+
+    toggleSelectAll() {
+        this.selectionManager.toggleSelectAll();
+    }
+
+    toggleSelectMember({ event, item }: { event: MouseEvent; item: SearchProducts.Items }) {
+        this.selectionManager.toggleSelection(item, event);
+    }
+
+    isMemberSelected = (product: SearchProducts.Items): boolean => {
+        return this.selectionManager.isSelected(product);
+    };
 }

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

@@ -18,6 +18,7 @@ export * from './components/facet-list/facet-list.component';
 export * from './components/generate-product-variants/generate-product-variants.component';
 export * from './components/option-value-input/option-value-input.component';
 export * from './components/product-detail/product-detail.component';
+export * from './components/product-list/product-list-bulk-actions';
 export * from './components/product-list/product-list.component';
 export * from './components/product-options-editor/product-options-editor.component';
 export * from './components/product-variants-editor/product-variants-editor.component';

+ 4 - 0
packages/admin-ui/src/lib/core/src/common/utilities/selection-manager.ts

@@ -67,6 +67,9 @@ export class SelectionManager<T> {
     }
 
     areAllCurrentItemsSelected(): boolean {
+        if (!this.items || this.items.length === 0) {
+            return false;
+        }
         return this.items.every(a => this._selection.find(b => this.options.itemsAreEqual(a, b)));
     }
 
@@ -76,6 +79,7 @@ export class SelectionManager<T> {
                 a => !this.items.find(b => this.options.itemsAreEqual(a, b)),
             );
         } else {
+            this._selection = this._selection.slice(0);
             for (const item of this.items) {
                 if (!this._selection.find(a => this.options.itemsAreEqual(a, item))) {
                     this._selection.push(item);

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

@@ -0,0 +1,24 @@
+import { Injectable, Type } from '@angular/core';
+import { FormInputComponent } from '@vendure/admin-ui/core';
+
+import { BulkAction, BulkActionLocationId } from './bulk-action-types';
+
+@Injectable({
+    providedIn: 'root',
+})
+export class BulkActionRegistryService {
+    private locationBulActionMap = new Map<BulkActionLocationId, Set<BulkAction>>();
+
+    registerBulkAction(bulkAction: BulkAction) {
+        if (!this.locationBulActionMap.has(bulkAction.location)) {
+            this.locationBulActionMap.set(bulkAction.location, new Set([bulkAction]));
+        } else {
+            // tslint:disable-next-line:no-non-null-assertion
+            this.locationBulActionMap.get(bulkAction.location)!.add(bulkAction);
+        }
+    }
+
+    getBulkActionsForLocation(id: BulkActionLocationId): BulkAction[] {
+        return [...(this.locationBulActionMap.get(id)?.values() ?? [])];
+    }
+}

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

@@ -0,0 +1,50 @@
+import { Injector } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+/**
+ * @description
+ * A valid location of a list view that supports the bulk actions API.
+ *
+ * @since 1.8.0
+ * @docsCategory bulk-actions
+ * @docsPage BulkAction
+ */
+export type BulkActionLocationId = 'product-list' | 'order-list' | string;
+
+/**
+ * @description
+ * This is the argument which gets passed to the `onClick` function of a BulkAction.
+ *
+ * @since 1.8.0
+ * @docsCategory bulk-actions
+ * @docsPage BulkAction
+ */
+export interface BulkActionClickContext<T> {
+    selection: T[];
+    event: MouseEvent;
+    route: ActivatedRoute;
+    injector: Injector;
+}
+
+/**
+ * @description
+ * Configures a bulk action which can be performed on all selected items in a list view.
+ *
+ * For a full example, see the {@link registerBulkAction} docs.
+ *
+ * @since 1.8.0
+ * @docsCategory bulk-actions
+ * @docsPage BulkAction
+ * @docsWeight 0
+ */
+export interface BulkAction<T = any> {
+    location: BulkActionLocationId;
+    label: string;
+    icon?: string;
+    iconClass?: string;
+    onClick: (context: BulkActionClickContext<T>) => void;
+    /**
+     * Control the display of this item based on the user permissions.
+     */
+    requiresPermission?: string | ((userPermissions: string[]) => boolean);
+}

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

@@ -0,0 +1,60 @@
+import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
+import { BulkAction, BulkActionRegistryService } from '@vendure/admin-ui/core';
+
+/**
+ * @description
+ * Registers a custom {@link BulkAction} which can be invoked from the bulk action menu
+ * of any supported list view.
+ *
+ * This allows you to provide custom functionality that can operate on any of the selected
+ * items in the list view.
+ *
+ * In this example, imagine we have an integration with a 3rd-party text translation service. This
+ * bulk action allows us to select multiple products from the product list view, and send them for
+ * translation via a custom service which integrates with the translation service's API.
+ *
+ * @example
+ * ```TypeScript
+ * \@NgModule({
+ *   imports: [SharedModule],
+ *   providers: [
+ *     ProductDataTranslationService,
+ *     registerBulkAction({
+ *       location: 'product-list',
+ *       label: 'Send to translation service',
+ *       icon: 'language',
+ *       onClick: ({ injector, selection }) => {
+ *         const modalService = injector.get(ModalService);
+ *         const translationService = injector.get(ProductDataTranslationService);
+ *         modalService
+ *           .dialog({
+ *             title: `Send ${selection.length} products for translation?`,
+ *             buttons: [
+ *               { type: 'secondary', label: 'cancel' },
+ *               { type: 'primary', label: 'send', returnValue: true },
+ *             ],
+ *           })
+ *           .subscribe(response => {
+ *             if (response) {
+ *               translationService.sendForTranslation(selection.map(item => item.productId));
+ *             }
+ *           });
+ *       },
+ *     }),
+ *   ],
+ * })
+ * export class MyUiExtensionModule {}
+ * ```
+ * @since 1.8.0
+ * @docsCategory bulk-actions
+ */
+export function registerBulkAction(bulkAction: BulkAction): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (registry: BulkActionRegistryService) => () => {
+            registry.registerBulkAction(bulkAction);
+        },
+        deps: [BulkActionRegistryService],
+    };
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -70,6 +70,9 @@ export * from './data/utils/get-server-location';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/transform-relation-custom-field-inputs';
 export * from './providers/auth/auth.service';
+export * from './providers/bulk-action-registry/bulk-action-registry.service';
+export * from './providers/bulk-action-registry/bulk-action-types';
+export * from './providers/bulk-action-registry/register-bulk-action';
 export * from './providers/component-registry/component-registry.service';
 export * from './providers/custom-detail-component/custom-detail-component-types';
 export * from './providers/custom-detail-component/custom-detail-component.service';
@@ -101,6 +104,7 @@ export * from './shared/components/asset-preview/asset-preview.component';
 export * from './shared/components/asset-preview-dialog/asset-preview-dialog.component';
 export * from './shared/components/asset-preview-links/asset-preview-links.component';
 export * from './shared/components/asset-search-input/asset-search-input.component';
+export * from './shared/components/bulk-action-menu/bulk-action-menu.component';
 export * from './shared/components/channel-assignment-control/channel-assignment-control.component';
 export * from './shared/components/channel-badge/channel-badge.component';
 export * from './shared/components/chip/chip.component';

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

@@ -0,0 +1,17 @@
+<vdr-dropdown *ngIf="actions.length">
+    <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>
+    </vdr-dropdown-menu>
+</vdr-dropdown>

+ 0 - 0
packages/admin-ui/src/lib/core/src/shared/components/bulk-action-menu/bulk-action-menu.component.scss


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

@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component, Injector, Input, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import { BulkActionRegistryService } from '../../../providers/bulk-action-registry/bulk-action-registry.service';
+import { BulkAction, BulkActionLocationId } from '../../../providers/bulk-action-registry/bulk-action-types';
+
+@Component({
+    selector: 'vdr-bulk-action-menu',
+    templateUrl: './bulk-action-menu.component.html',
+    styleUrls: ['./bulk-action-menu.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class BulkActionMenuComponent<T = any> implements OnInit {
+    @Input() locationId: BulkActionLocationId;
+    @Input() selection: T[];
+    actions: Array<BulkAction<T>>;
+
+    constructor(
+        private bulkActionRegistryService: BulkActionRegistryService,
+        private injector: Injector,
+        private route: ActivatedRoute,
+    ) {}
+
+    ngOnInit(): void {
+        this.actions = this.bulkActionRegistryService.getBulkActionsForLocation(this.locationId);
+    }
+
+    actionClick(event: MouseEvent, action: BulkAction) {
+        action.onClick({
+            injector: this.injector,
+            event,
+            route: this.route,
+            selection: this.selection,
+        });
+    }
+}

+ 10 - 0
packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.scss

@@ -5,6 +5,16 @@
 .dropdown-content-wrapper {
 }
 
+::ng-deep {
+    .dropdown-menu .dropdown-item {
+        display: flex;
+        align-items: center;
+        clr-icon {
+            margin-right: 3px;
+        }
+    }
+}
+
 .dropdown.open > .dropdown-menu {
     position: relative;
     top: 0;

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

@@ -30,6 +30,7 @@ import { AssetPreviewDialogComponent } from './components/asset-preview-dialog/a
 import { AssetPreviewLinksComponent } from './components/asset-preview-links/asset-preview-links.component';
 import { AssetPreviewComponent } from './components/asset-preview/asset-preview.component';
 import { AssetSearchInputComponent } from './components/asset-search-input/asset-search-input.component';
+import { BulkActionMenuComponent } from './components/bulk-action-menu/bulk-action-menu.component';
 import { ChannelAssignmentControlComponent } from './components/channel-assignment-control/channel-assignment-control.component';
 import { ChannelBadgeComponent } from './components/channel-badge/channel-badge.component';
 import { ChipComponent } from './components/chip/chip.component';
@@ -245,6 +246,7 @@ const DECLARATIONS = [
     ProductSearchInputComponent,
     ContextMenuComponent,
     RawHtmlDialogComponent,
+    BulkActionMenuComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 1 - 1
packages/admin-ui/src/lib/static/styles/global/_overrides.scss

@@ -34,7 +34,7 @@ a:focus, button:focus {
 .table {
     border-color: var(--color-component-border-100);
 
-    td.align-middle {
+    td.align-middle, th.align-middle {
         vertical-align: middle!important;
     }