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
[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>
@ -25,12 +46,17 @@
<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>

View 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);
}
}

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 { 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
}
}

View 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>

View 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">

View 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;
@ -30,6 +37,11 @@
}
}
.dropdown-toggle::after {
position: relative;
top: 1px;
}
.dropdown-menu {
.dropdown-item {
cursor: pointer;

View 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'
}

View 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>

View 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()
},

View 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>

View 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(

View 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) {

View File

@ -14,8 +14,11 @@
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);
}
}
}