Add bulk actions in users table
authorChocobozzz <me@florianbigard.com>
Mon, 8 Oct 2018 13:15:11 +0000 (15:15 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 8 Oct 2018 13:55:32 +0000 (15:55 +0200)
13 files changed:
client/src/app/+admin/users/user-list/user-list.component.html
client/src/app/+admin/users/user-list/user-list.component.scss
client/src/app/+admin/users/user-list/user-list.component.ts
client/src/app/header/header.component.html
client/src/app/shared/buttons/action-dropdown.component.html
client/src/app/shared/buttons/action-dropdown.component.scss
client/src/app/shared/buttons/action-dropdown.component.ts
client/src/app/shared/moderation/user-ban-modal.component.html
client/src/app/shared/moderation/user-ban-modal.component.ts
client/src/app/shared/moderation/user-moderation-dropdown.component.html
client/src/app/shared/moderation/user-moderation-dropdown.component.ts
client/src/app/shared/users/user.service.ts
client/src/sass/primeng-custom.scss

index cca057ba1dee6f4dc729252df9e15501856c4ecb..9d1f2e34aa620614d490baba79182e3cc30bc141 100644 (file)
 <p-table
   [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
   [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
+  [(selection)]="selectedUsers"
 >
+  <ng-template pTemplate="caption">
+    <div class="caption">
+      <div>
+        <my-action-dropdown
+          *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
+          [actions]="bulkUserActions" [entry]="selectedUsers"
+        >
+        </my-action-dropdown>
+      </div>
+
+      <div>
+        <input
+          type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+        >
+      </div>
+    </div>
+  </ng-template>
+
   <ng-template pTemplate="header">
     <tr>
       <th style="width: 40px"></th>
+      <th style="width: 40px">
+      </th>
       <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
       <th i18n>Email</th>
       <th i18n>Video quota</th>
 
   <ng-template pTemplate="body" let-expanded="expanded" let-user>
 
-    <tr [ngClass]="{ banned: user.blocked }">
+    <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }">
+      <td>
+        <p-tableCheckbox [value]="user"></p-tableCheckbox>
+      </td>
+
       <td>
         <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user">
           <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
         </span>
       </td>
+
       <td>
         {{ user.username }}
         <span *ngIf="user.blocked" class="banned-info">(banned)</span>
@@ -40,7 +66,7 @@
       <td>{{ user.roleLabel }}</td>
       <td>{{ user.createdAt }}</td>
       <td class="action-cell">
-        <my-user-moderation-dropdown [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
+        <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()">
         </my-user-moderation-dropdown>
       </td>
     </tr>
@@ -56,3 +82,4 @@
   </ng-template>
 </p-table>
 
+<my-user-ban-modal #userBanModal (userBanned)="onUsersBanned()"></my-user-ban-modal>
index 47291918dffd91643a05689a816061c8bd60d2ee..01f43dfe141c3fcf5c9c536ff853e9f22c333ee8 100644 (file)
@@ -15,4 +15,15 @@ tr.banned {
 
 .ban-reason-label {
   font-weight: $font-semibold;
+}
+
+.caption {
+  height: 40px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  input {
+    @include peertube-input-text(250px);
+  }
 }
\ No newline at end of file
index dee3ed6431fd5b82a747f1aa7682778d1fea1e82..f3e7e0ead8aad4998d988ebce38810f8a484ede3 100644 (file)
@@ -1,10 +1,12 @@
-import { Component, OnInit } from '@angular/core'
+import { Component, OnInit, ViewChild } from '@angular/core'
 import { NotificationsService } from 'angular2-notifications'
 import { SortMeta } from 'primeng/components/common/sortmeta'
 import { ConfirmService } from '../../../core'
 import { RestPagination, RestTable, UserService } from '../../../shared'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { User } from '../../../../../../shared'
+import { UserBanModalComponent } from '@app/shared/moderation'
+import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
 
 @Component({
   selector: 'my-user-list',
@@ -12,12 +14,17 @@ import { User } from '../../../../../../shared'
   styleUrls: [ './user-list.component.scss' ]
 })
 export class UserListComponent extends RestTable implements OnInit {
+  @ViewChild('userBanModal') userBanModal: UserBanModalComponent
+
   users: User[] = []
   totalRecords = 0
   rowsPerPage = 10
   sort: SortMeta = { field: 'createdAt', order: 1 }
   pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
 
+  selectedUsers: User[] = []
+  bulkUserActions: DropdownAction<User>[] = []
+
   constructor (
     private notificationsService: NotificationsService,
     private confirmService: ConfirmService,
@@ -29,13 +36,28 @@ export class UserListComponent extends RestTable implements OnInit {
 
   ngOnInit () {
     this.loadSort()
-  }
 
-  onUserChanged () {
-    this.loadData()
+    this.bulkUserActions = [
+      {
+        label: this.i18n('Delete'),
+        handler: users => this.removeUsers(users)
+      },
+      {
+        label: this.i18n('Ban'),
+        handler: users => this.openBanUserModal(users),
+        isDisplayed: users => users.every(u => u.blocked === false)
+      },
+      {
+        label: this.i18n('Unban'),
+        handler: users => this.unbanUsers(users),
+        isDisplayed: users => users.every(u => u.blocked === true)
+      }
+    ]
   }
 
   protected loadData () {
+    this.selectedUsers = []
+
     this.userService.getUsers(this.pagination, this.sort)
                     .subscribe(
                       resultList => {
@@ -46,4 +68,67 @@ export class UserListComponent extends RestTable implements OnInit {
                       err => this.notificationsService.error(this.i18n('Error'), err.message)
                     )
   }
+
+  openBanUserModal (users: User[]) {
+    for (const user of users) {
+      if (user.username === 'root') {
+        this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
+        return
+      }
+    }
+
+    this.userBanModal.openModal(users)
+  }
+
+  onUsersBanned () {
+    this.loadData()
+  }
+
+  async unbanUsers (users: User[]) {
+    const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length })
+
+    const res = await this.confirmService.confirm(message, this.i18n('Unban'))
+    if (res === false) return
+
+    this.userService.unbanUsers(users)
+        .subscribe(
+          () => {
+            const message = this.i18n('{{num}} users unbanned.', { num: users.length })
+
+            this.notificationsService.success(this.i18n('Success'), message)
+            this.loadData()
+          },
+
+          err => this.notificationsService.error(this.i18n('Error'), err.message)
+        )
+  }
+
+  async removeUsers (users: User[]) {
+    for (const user of users) {
+      if (user.username === 'root') {
+        this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot delete root.'))
+        return
+      }
+    }
+
+    const message = this.i18n('If you remove these users, you will not be able to create others with the same username!')
+    const res = await this.confirmService.confirm(message, this.i18n('Delete'))
+    if (res === false) return
+
+    this.userService.removeUser(users).subscribe(
+      () => {
+        this.notificationsService.success(
+          this.i18n('Success'),
+          this.i18n('{{num}} users deleted.', { num: users.length })
+        )
+        this.loadData()
+      },
+
+      err => this.notificationsService.error(this.i18n('Error'), err.message)
+    )
+  }
+
+  isInSelectionMode () {
+    return this.selectedUsers.length !== 0
+  }
 }
index a04354db5c75c2dda9357a74d1d6e3a33f03908d..c23e0c55d9ef1d213f0f4d8ece69015953fdd49d 100644 (file)
@@ -1,6 +1,6 @@
 <input
-    type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..."
-    [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
+  type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..."
+  [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
 >
 <span (click)="doSearch()" class="icon icon-search"></span>
 
index 8110e25158eebd6d243aa51823a75e8d9531f9d8..11162742474c8058d8ec165493ed58d0369f5ce5 100644 (file)
@@ -1,6 +1,10 @@
 <div class="dropdown-root" ngbDropdown [placement]="placement">
-  <div class="action-button" [ngClass]="{ small: buttonSize === 'small' }" ngbDropdownToggle role="button">
-    <span class="icon icon-action"></span>
+  <div
+    class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }"
+    ngbDropdownToggle role="button"
+  >
+    <span *ngIf="!label" class="icon icon-action"></span>
+    <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
   </div>
 
   <div ngbDropdownMenu class="dropdown-menu">
index 00f120fb8ea5831292bb334544a290703d89ade6..0a9aa7b0460156cec2f483699b47eec09e589101 100644 (file)
@@ -3,7 +3,14 @@
 
 .action-button {
   @include peertube-button;
-  @include grey-button;
+
+  &.grey {
+    @include grey-button;
+  }
+
+  &.orange {
+    @include orange-button;
+  }
 
   display: inline-block;
   padding: 0 10px;
   }
 }
 
+.dropdown-toggle::after {
+  position: relative;
+  top: 1px;
+}
+
 .dropdown-menu {
   .dropdown-item {
     cursor: pointer;
index 1838ff697e3953bc1fb91895894a3bdcaa262697..022ab5ee810c3156030c4752ff2d24e47561dd52 100644 (file)
@@ -16,6 +16,8 @@ export type DropdownAction<T> = {
 export class ActionDropdownComponent<T> {
   @Input() actions: DropdownAction<T>[] = []
   @Input() entry: T
-  @Input() placement = 'left'
+  @Input() placement = 'bottom-left'
   @Input() buttonSize: 'normal' | 'small' = 'normal'
+  @Input() label: string
+  @Input() theme: 'orange' | 'grey' = 'grey'
 }
index b2958caa471b13926853cad44152939cab16f4fe..fa5cb740472c4f3f358636faeb2ce7a9b81183d5 100644 (file)
@@ -1,6 +1,6 @@
 <ng-template #modal>
   <div class="modal-header">
-    <h4 i18n class="modal-title">Ban {{ userToBan.username }}</h4>
+    <h4 i18n class="modal-title">Ban</h4>
     <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span>
   </div>
 
index 67ae38e487ad1d11ec6abcac20a8c78d923fbd4b..60bd442dd1354cbe526eabca58fc756f5dd466c0 100644 (file)
@@ -15,9 +15,9 @@ import { User } from '../../../../../shared'
 })
 export class UserBanModalComponent extends FormReactive implements OnInit {
   @ViewChild('modal') modal: NgbModal
-  @Output() userBanned = new EventEmitter<User>()
+  @Output() userBanned = new EventEmitter<User | User[]>()
 
-  private userToBan: User
+  private usersToBan: User | User[]
   private openedModal: NgbModalRef
 
   constructor (
@@ -37,28 +37,29 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
     })
   }
 
-  openModal (user: User) {
-    this.userToBan = user
+  openModal (user: User | User[]) {
+    this.usersToBan = user
     this.openedModal = this.modalService.open(this.modal)
   }
 
   hideBanUserModal () {
-    this.userToBan = undefined
+    this.usersToBan = undefined
     this.openedModal.close()
   }
 
   async banUser () {
     const reason = this.form.value['reason'] || undefined
 
-    this.userService.banUser(this.userToBan, reason)
+    this.userService.banUsers(this.usersToBan, reason)
       .subscribe(
         () => {
-          this.notificationsService.success(
-            this.i18n('Success'),
-            this.i18n('User {{username}} banned.', { username: this.userToBan.username })
-          )
+          const message = Array.isArray(this.usersToBan)
+            ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
+            : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
 
-          this.userBanned.emit(this.userToBan)
+          this.notificationsService.success(this.i18n('Success'), message)
+
+          this.userBanned.emit(this.usersToBan)
           this.hideBanUserModal()
         },
 
index ed1a4c86350757f80b6b6594e076de55d2367dda..2c477ab23d251151f3e5bc373d5743b6ca7bef5e 100644 (file)
@@ -1,5 +1,5 @@
 <ng-container *ngIf="user && userActions.length !== 0">
   <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
 
-  <my-action-dropdown i18n-label label="Actions" [actions]="userActions" [entry]="user" [buttonSize]="buttonSize"></my-action-dropdown>
+  <my-action-dropdown [actions]="userActions" [entry]="user" [buttonSize]="buttonSize"></my-action-dropdown>
 </ng-container>
\ No newline at end of file
index 4f88456defa03212407443483cfa890a47def945..174e9f024c82119f8ef71a7bc7a7e63a4017b32b 100644 (file)
@@ -2,7 +2,6 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angu
 import { NotificationsService } from 'angular2-notifications'
 import { I18n } from '@ngx-translate/i18n-polyfill'
 import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
 import { UserService } from '@app/shared/users'
 import { AuthService, ConfirmService } from '@app/core'
@@ -24,8 +23,6 @@ export class UserModerationDropdownComponent implements OnInit {
 
   userActions: DropdownAction<User>[] = []
 
-  private openedModal: NgbModalRef
-
   constructor (
     private authService: AuthService,
     private notificationsService: NotificationsService,
@@ -38,10 +35,6 @@ export class UserModerationDropdownComponent implements OnInit {
     this.buildActions()
   }
 
-  hideBanUserModal () {
-    this.openedModal.close()
-  }
-
   openBanUserModal (user: User) {
     if (user.username === 'root') {
       this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban root.'))
@@ -60,7 +53,7 @@ export class UserModerationDropdownComponent implements OnInit {
     const res = await this.confirmService.confirm(message, this.i18n('Unban'))
     if (res === false) return
 
-    this.userService.unbanUser(user)
+    this.userService.unbanUsers(user)
         .subscribe(
           () => {
             this.notificationsService.success(
index d9b81c181f5ae5ec7873a716f149ef8f2090b6ee..0eb3870b054c93f4b0d1b491c369e6d01d290a87 100644 (file)
@@ -1,5 +1,5 @@
-import { Observable } from 'rxjs'
-import { catchError, map } from 'rxjs/operators'
+import { from, Observable } from 'rxjs'
+import { catchError, concatMap, map, toArray } from 'rxjs/operators'
 import { HttpClient, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
@@ -170,21 +170,38 @@ export class UserService {
                )
   }
 
-  removeUser (user: { id: number }) {
-    return this.authHttp.delete(UserService.BASE_USERS_URL + user.id)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  removeUser (usersArg: User | User[]) {
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
   }
 
-  banUser (user: { id: number }, reason?: string) {
+  banUsers (usersArg: User | User[], reason?: string) {
     const body = reason ? { reason } : {}
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
 
-    return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body)
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
   }
 
-  unbanUser (user: { id: number }) {
-    return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {})
-               .pipe(catchError(err => this.restExtractor.handleError(err)))
+  unbanUsers (usersArg: User | User[]) {
+    const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+    return from(users)
+      .pipe(
+        concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
+        toArray(),
+        catchError(err => this.restExtractor.handleError(err))
+      )
   }
 
   private formatUser (user: User) {
index 5a03ac9c530d5ce06e43bfc1cf3be8ca878e9f97..4a19e0275463b2cf4146f8c72e0d8fc4f8c4749c 100644 (file)
 p-table {
   font-size: 15px !important;
 
+  .ui-table-caption {
+    border: none;
+  }
+
   td {
-    // border: 1px solid #E5E5E5 !important;
     padding-left: 15px !important;
 
     &:not(.action-cell) {
@@ -28,6 +31,11 @@ p-table {
   tr {
     background-color: var(--mainBackgroundColor) !important;
     height: 46px;
+
+    &.ui-state-highlight {
+      background-color:var(--submenuColor) !important;
+      color:var(--mainForegroundColor) !important;
+    }
   }
 
   .ui-table-tbody {
@@ -216,4 +224,32 @@ p-calendar .ui-datepicker {
       @include glyphicon-light;
     }
   }
+}
+
+.ui-chkbox-box {
+  &.ui-state-active {
+    border-color: var(--mainColor) !important;
+    background-color: var(--mainColor) !important;
+  }
+
+  .ui-chkbox-icon {
+    position: relative;
+
+    &:after {
+      content: '';
+      position: absolute;
+      left: 5px;
+      width: 5px;
+      height: 12px;
+      opacity: 0;
+      transform: rotate(45deg) scale(0);
+      border-right: 2px solid var(--mainBackgroundColor);
+      border-bottom: 2px solid var(--mainBackgroundColor);
+    }
+
+    &.pi-check:after {
+      opacity: 1;
+      transform: rotate(45deg) scale(1);
+    }
+  }
 }
\ No newline at end of file