Add bulk actions in users table

pull/1173/head
Chocobozzz 2018-10-08 15:15:11 +02:00
parent 80c7336a89
commit 791645e620
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
13 changed files with 233 additions and 45 deletions

View File

@ -10,10 +10,31 @@
<p-table <p-table
[value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage"
[sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [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"> <ng-template pTemplate="header">
<tr> <tr>
<th style="width: 40px"></th> <th style="width: 40px"></th>
<th style="width: 40px">
</th>
<th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th>
<th i18n>Email</th> <th i18n>Email</th>
<th i18n>Video quota</th> <th i18n>Video quota</th>
@ -25,12 +46,17 @@
<ng-template pTemplate="body" let-expanded="expanded" let-user> <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> <td>
<span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span> </span>
</td> </td>
<td> <td>
{{ user.username }} {{ user.username }}
<span *ngIf="user.blocked" class="banned-info">(banned)</span> <span *ngIf="user.blocked" class="banned-info">(banned)</span>
@ -40,7 +66,7 @@
<td>{{ user.roleLabel }}</td> <td>{{ user.roleLabel }}</td>
<td>{{ user.createdAt }}</td> <td>{{ user.createdAt }}</td>
<td class="action-cell"> <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> </my-user-moderation-dropdown>
</td> </td>
</tr> </tr>
@ -56,3 +82,4 @@
</ng-template> </ng-template>
</p-table> </p-table>
<my-user-ban-modal #userBanModal (userBanned)="onUsersBanned()"></my-user-ban-modal>

View File

@ -15,4 +15,15 @@ tr.banned {
.ban-reason-label { .ban-reason-label {
font-weight: $font-semibold; font-weight: $font-semibold;
}
.caption {
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
input {
@include peertube-input-text(250px);
}
} }

View File

@ -1,10 +1,12 @@
import { Component, OnInit } from '@angular/core' import { Component, OnInit, ViewChild } from '@angular/core'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
import { SortMeta } from 'primeng/components/common/sortmeta' import { SortMeta } from 'primeng/components/common/sortmeta'
import { ConfirmService } from '../../../core' import { ConfirmService } from '../../../core'
import { RestPagination, RestTable, UserService } from '../../../shared' import { RestPagination, RestTable, UserService } from '../../../shared'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { User } from '../../../../../../shared' import { User } from '../../../../../../shared'
import { UserBanModalComponent } from '@app/shared/moderation'
import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
@Component({ @Component({
selector: 'my-user-list', selector: 'my-user-list',
@ -12,12 +14,17 @@ import { User } from '../../../../../../shared'
styleUrls: [ './user-list.component.scss' ] styleUrls: [ './user-list.component.scss' ]
}) })
export class UserListComponent extends RestTable implements OnInit { export class UserListComponent extends RestTable implements OnInit {
@ViewChild('userBanModal') userBanModal: UserBanModalComponent
users: User[] = [] users: User[] = []
totalRecords = 0 totalRecords = 0
rowsPerPage = 10 rowsPerPage = 10
sort: SortMeta = { field: 'createdAt', order: 1 } sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
selectedUsers: User[] = []
bulkUserActions: DropdownAction<User>[] = []
constructor ( constructor (
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private confirmService: ConfirmService, private confirmService: ConfirmService,
@ -29,13 +36,28 @@ export class UserListComponent extends RestTable implements OnInit {
ngOnInit () { ngOnInit () {
this.loadSort() this.loadSort()
}
onUserChanged () { this.bulkUserActions = [
this.loadData() {
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 () { protected loadData () {
this.selectedUsers = []
this.userService.getUsers(this.pagination, this.sort) this.userService.getUsers(this.pagination, this.sort)
.subscribe( .subscribe(
resultList => { resultList => {
@ -46,4 +68,67 @@ export class UserListComponent extends RestTable implements OnInit {
err => this.notificationsService.error(this.i18n('Error'), err.message) 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
}
} }

View File

@ -1,6 +1,6 @@
<input <input
type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..." type="text" id="search-video" name="search-video" i18n-placeholder placeholder="Search..."
[(ngModel)]="searchValue" (keyup.enter)="doSearch()" [(ngModel)]="searchValue" (keyup.enter)="doSearch()"
> >
<span (click)="doSearch()" class="icon icon-search"></span> <span (click)="doSearch()" class="icon icon-search"></span>

View File

@ -1,6 +1,10 @@
<div class="dropdown-root" ngbDropdown [placement]="placement"> <div class="dropdown-root" ngbDropdown [placement]="placement">
<div class="action-button" [ngClass]="{ small: buttonSize === 'small' }" ngbDropdownToggle role="button"> <div
<span class="icon icon-action"></span> 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>
<div ngbDropdownMenu class="dropdown-menu"> <div ngbDropdownMenu class="dropdown-menu">

View File

@ -3,7 +3,14 @@
.action-button { .action-button {
@include peertube-button; @include peertube-button;
@include grey-button;
&.grey {
@include grey-button;
}
&.orange {
@include orange-button;
}
display: inline-block; display: inline-block;
padding: 0 10px; padding: 0 10px;
@ -30,6 +37,11 @@
} }
} }
.dropdown-toggle::after {
position: relative;
top: 1px;
}
.dropdown-menu { .dropdown-menu {
.dropdown-item { .dropdown-item {
cursor: pointer; cursor: pointer;

View File

@ -16,6 +16,8 @@ export type DropdownAction<T> = {
export class ActionDropdownComponent<T> { export class ActionDropdownComponent<T> {
@Input() actions: DropdownAction<T>[] = [] @Input() actions: DropdownAction<T>[] = []
@Input() entry: T @Input() entry: T
@Input() placement = 'left' @Input() placement = 'bottom-left'
@Input() buttonSize: 'normal' | 'small' = 'normal' @Input() buttonSize: 'normal' | 'small' = 'normal'
@Input() label: string
@Input() theme: 'orange' | 'grey' = 'grey'
} }

View File

@ -1,6 +1,6 @@
<ng-template #modal> <ng-template #modal>
<div class="modal-header"> <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> <span class="close" aria-hidden="true" (click)="hideBanUserModal()"></span>
</div> </div>

View File

@ -15,9 +15,9 @@ import { User } from '../../../../../shared'
}) })
export class UserBanModalComponent extends FormReactive implements OnInit { export class UserBanModalComponent extends FormReactive implements OnInit {
@ViewChild('modal') modal: NgbModal @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 private openedModal: NgbModalRef
constructor ( constructor (
@ -37,28 +37,29 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
}) })
} }
openModal (user: User) { openModal (user: User | User[]) {
this.userToBan = user this.usersToBan = user
this.openedModal = this.modalService.open(this.modal) this.openedModal = this.modalService.open(this.modal)
} }
hideBanUserModal () { hideBanUserModal () {
this.userToBan = undefined this.usersToBan = undefined
this.openedModal.close() this.openedModal.close()
} }
async banUser () { async banUser () {
const reason = this.form.value['reason'] || undefined const reason = this.form.value['reason'] || undefined
this.userService.banUser(this.userToBan, reason) this.userService.banUsers(this.usersToBan, reason)
.subscribe( .subscribe(
() => { () => {
this.notificationsService.success( const message = Array.isArray(this.usersToBan)
this.i18n('Success'), ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
this.i18n('User {{username}} banned.', { username: this.userToBan.username }) : 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() this.hideBanUserModal()
}, },

View File

@ -1,5 +1,5 @@
<ng-container *ngIf="user && userActions.length !== 0"> <ng-container *ngIf="user && userActions.length !== 0">
<my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal> <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> </ng-container>

View File

@ -2,7 +2,6 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angu
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' 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 { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
import { UserService } from '@app/shared/users' import { UserService } from '@app/shared/users'
import { AuthService, ConfirmService } from '@app/core' import { AuthService, ConfirmService } from '@app/core'
@ -24,8 +23,6 @@ export class UserModerationDropdownComponent implements OnInit {
userActions: DropdownAction<User>[] = [] userActions: DropdownAction<User>[] = []
private openedModal: NgbModalRef
constructor ( constructor (
private authService: AuthService, private authService: AuthService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
@ -38,10 +35,6 @@ export class UserModerationDropdownComponent implements OnInit {
this.buildActions() this.buildActions()
} }
hideBanUserModal () {
this.openedModal.close()
}
openBanUserModal (user: User) { openBanUserModal (user: User) {
if (user.username === 'root') { if (user.username === 'root') {
this.notificationsService.error(this.i18n('Error'), this.i18n('You cannot ban 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')) const res = await this.confirmService.confirm(message, this.i18n('Unban'))
if (res === false) return if (res === false) return
this.userService.unbanUser(user) this.userService.unbanUsers(user)
.subscribe( .subscribe(
() => { () => {
this.notificationsService.success( this.notificationsService.success(

View File

@ -1,5 +1,5 @@
import { Observable } from 'rxjs' import { from, Observable } from 'rxjs'
import { catchError, map } from 'rxjs/operators' import { catchError, concatMap, map, toArray } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
@ -170,21 +170,38 @@ export class UserService {
) )
} }
removeUser (user: { id: number }) { removeUser (usersArg: User | User[]) {
return this.authHttp.delete(UserService.BASE_USERS_URL + user.id) const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
.pipe(catchError(err => this.restExtractor.handleError(err)))
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 body = reason ? { reason } : {}
const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/block', body) return from(users)
.pipe(catchError(err => this.restExtractor.handleError(err))) .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 }) { unbanUsers (usersArg: User | User[]) {
return this.authHttp.post(UserService.BASE_USERS_URL + user.id + '/unblock', {}) const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
.pipe(catchError(err => this.restExtractor.handleError(err)))
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) { private formatUser (user: User) {

View File

@ -14,8 +14,11 @@
p-table { p-table {
font-size: 15px !important; font-size: 15px !important;
.ui-table-caption {
border: none;
}
td { td {
// border: 1px solid #E5E5E5 !important;
padding-left: 15px !important; padding-left: 15px !important;
&:not(.action-cell) { &:not(.action-cell) {
@ -28,6 +31,11 @@ p-table {
tr { tr {
background-color: var(--mainBackgroundColor) !important; background-color: var(--mainBackgroundColor) !important;
height: 46px; height: 46px;
&.ui-state-highlight {
background-color:var(--submenuColor) !important;
color:var(--mainForegroundColor) !important;
}
} }
.ui-table-tbody { .ui-table-tbody {
@ -216,4 +224,32 @@ p-calendar .ui-datepicker {
@include glyphicon-light; @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);
}
}
} }