mirror of
https://github.com/google/nomulus
synced 2025-12-23 06:15:42 +00:00
Add console bulk delete (#2641)
* Add bulk actions to console * Add console bulk delete * Add console bulk delete
This commit is contained in:
@@ -26,7 +26,11 @@ import { BackendService } from './shared/services/backend.service';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
|
||||
import { BillingInfoComponent } from './billingInfo/billingInfo.component';
|
||||
import { DomainListComponent } from './domains/domainList.component';
|
||||
import {
|
||||
DomainListComponent,
|
||||
ReasonDialogComponent,
|
||||
ResponseDialogComponent,
|
||||
} from './domains/domainList.component';
|
||||
import { RegistryLockComponent } from './domains/registryLock.component';
|
||||
import { HeaderComponent } from './header/header.component';
|
||||
import { HomeComponent } from './home/home.component';
|
||||
@@ -92,6 +96,8 @@ export class SelectedRegistrarModule {}
|
||||
TldsComponent,
|
||||
WhoisComponent,
|
||||
WhoisEditComponent,
|
||||
ReasonDialogComponent,
|
||||
ResponseDialogComponent,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
imports: [
|
||||
|
||||
@@ -65,16 +65,66 @@
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<div
|
||||
class="console-app__domains-selection"
|
||||
[elementId]="getElementIdForBulkDelete()"
|
||||
[ngClass]="{ active: selection.hasValue() }"
|
||||
>
|
||||
<div class="console-app__domains-selection-text">
|
||||
{{ selection.selected.length }} Selected
|
||||
</div>
|
||||
<div class="console-app__domains-selection-actions">
|
||||
<button
|
||||
mat-flat-button
|
||||
aria-label="Delete Selected Domains"
|
||||
(click)="deleteSelectedDomains()"
|
||||
>
|
||||
Delete Selected Domains
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-table
|
||||
[dataSource]="dataSource"
|
||||
class="mat-elevation-z0"
|
||||
class="console-app__domains-table"
|
||||
>
|
||||
<!-- Checkbox Column -->
|
||||
<ng-container matColumnDef="select">
|
||||
<mat-header-cell *matHeaderCellDef>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[checked]="selection.hasValue() && isAllSelected"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[elementId]="getElementIdForBulkDelete()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</mat-header-cell>
|
||||
<mat-cell *matCellDef="let row">
|
||||
<mat-checkbox
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="$event ? selection.toggle(row) : null"
|
||||
[checked]="selection.isSelected(row)"
|
||||
[aria-label]="checkboxLabel(row)"
|
||||
[elementId]="getElementIdForBulkDelete()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="domainName">
|
||||
<mat-header-cell *matHeaderCellDef>Domain Name</mat-header-cell>
|
||||
<mat-cell *matCellDef="let element">{{
|
||||
element.domainName
|
||||
}}</mat-cell>
|
||||
<mat-cell *matCellDef="let element">
|
||||
<mat-icon
|
||||
*ngIf="getOperationMessage(element.domainName)"
|
||||
[matTooltip]="getOperationMessage(element.domainName)"
|
||||
matTooltipPosition="above"
|
||||
class="primary-text"
|
||||
>info</mat-icon
|
||||
>
|
||||
<span>{{ element.domainName }}</span>
|
||||
</mat-cell>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="creationTime">
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__domains-selection {
|
||||
height: 60px;
|
||||
max-height: 0;
|
||||
transition: max-height 0.2s linear;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
gap: 20px;
|
||||
&-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
&.active {
|
||||
max-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
&-domains__download {
|
||||
position: absolute;
|
||||
top: -55px;
|
||||
@@ -41,6 +57,22 @@
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mat-column-select {
|
||||
max-width: 60px;
|
||||
padding-left: 15px;
|
||||
}
|
||||
.mat-column-domainName {
|
||||
position: relative;
|
||||
padding-left: 25px;
|
||||
mat-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
mat-cell:has([style*="display: none"]),
|
||||
mat-header-cell:has([style*="display: none"]) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__domains-spinner {
|
||||
|
||||
@@ -12,16 +12,91 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
|
||||
import { Component, ViewChild, effect } from '@angular/core';
|
||||
import { Component, ViewChild, effect, Inject } from '@angular/core';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Subject, debounceTime } from 'rxjs';
|
||||
import { Subject, debounceTime, take, filter } from 'rxjs';
|
||||
import { RegistrarService } from '../registrar/registrar.service';
|
||||
import { Domain, DomainListService } from './domainList.service';
|
||||
import { RegistryLockComponent } from './registryLock.component';
|
||||
import { RegistryLockService } from './registryLock.service';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive';
|
||||
|
||||
interface DomainResponse {
|
||||
message: string;
|
||||
responseCode: string;
|
||||
}
|
||||
|
||||
interface DomainData {
|
||||
[domain: string]: DomainResponse;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-response-dialog',
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content [innerHTML]="data.content" />
|
||||
<mat-dialog-actions>
|
||||
<button mat-button (click)="onClose()">Close</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class ResponseDialogComponent {
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ReasonDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA)
|
||||
public data: { title: string; content: string }
|
||||
) {}
|
||||
|
||||
onClose(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-reason-dialog',
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
Please provide a reason for {{ data.operation }} the domain(s):
|
||||
</h2>
|
||||
<mat-dialog-content>
|
||||
<mat-form-field appearance="outline" style="width:100%">
|
||||
<textarea matInput [(ngModel)]="reason" rows="4"></textarea>
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions>
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-button color="warn" (click)="onDelete()" [disabled]="!reason">
|
||||
Delete
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class ReasonDialogComponent {
|
||||
reason: string = '';
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<ReasonDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA)
|
||||
public data: { operation: 'deleting' | 'suspending' }
|
||||
) {}
|
||||
|
||||
onDelete(): void {
|
||||
this.dialogRef.close(this.reason);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-domain-list',
|
||||
@@ -31,8 +106,10 @@ import { RegistryLockService } from './registryLock.service';
|
||||
export class DomainListComponent {
|
||||
public static PATH = 'domain-list';
|
||||
private readonly DEBOUNCE_MS = 500;
|
||||
isAllSelected = false;
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'select',
|
||||
'domainName',
|
||||
'creationTime',
|
||||
'registrationExpirationTime',
|
||||
@@ -42,6 +119,7 @@ export class DomainListComponent {
|
||||
];
|
||||
|
||||
dataSource: MatTableDataSource<Domain> = new MatTableDataSource();
|
||||
selection = new SelectionModel<Domain>(true, [], undefined, this.isChecked());
|
||||
isLoading = true;
|
||||
|
||||
searchTermSubject = new Subject<string>();
|
||||
@@ -51,13 +129,18 @@ export class DomainListComponent {
|
||||
resultsPerPage = 50;
|
||||
totalResults?: number = 0;
|
||||
|
||||
reason: string = '';
|
||||
|
||||
operationResult: DomainData | undefined;
|
||||
|
||||
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
|
||||
|
||||
constructor(
|
||||
protected domainListService: DomainListService,
|
||||
protected registrarService: RegistrarService,
|
||||
protected registryLockService: RegistryLockService,
|
||||
private _snackBar: MatSnackBar
|
||||
private _snackBar: MatSnackBar,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
effect(() => {
|
||||
this.pageNumber = 0;
|
||||
@@ -134,6 +217,98 @@ export class DomainListComponent {
|
||||
onPageChange(event: PageEvent) {
|
||||
this.pageNumber = event.pageIndex;
|
||||
this.resultsPerPage = event.pageSize;
|
||||
this.selection.clear();
|
||||
this.reloadData();
|
||||
}
|
||||
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected) {
|
||||
this.selection.clear();
|
||||
this.isAllSelected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource.data);
|
||||
this.isAllSelected = true;
|
||||
}
|
||||
|
||||
checkboxLabel(row?: Domain): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.domainName
|
||||
}`;
|
||||
}
|
||||
|
||||
private isChecked(): ((o1: Domain, o2: Domain) => boolean) | undefined {
|
||||
return (o1: Domain, o2: Domain) => {
|
||||
if (!o1.domainName || !o2.domainName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isAllSelected || o1.domainName === o2.domainName;
|
||||
};
|
||||
}
|
||||
|
||||
getElementIdForBulkDelete() {
|
||||
return RESTRICTED_ELEMENTS.BULK_DELETE;
|
||||
}
|
||||
|
||||
getOperationMessage(domain: string) {
|
||||
if (this.operationResult && this.operationResult[domain])
|
||||
return this.operationResult[domain].message;
|
||||
return '';
|
||||
}
|
||||
|
||||
sendDeleteRequest(reason: string) {
|
||||
this.isLoading = true;
|
||||
this.domainListService
|
||||
.deleteDomains(
|
||||
this.selection.selected,
|
||||
reason,
|
||||
this.registrarService.registrarId()
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (result: DomainData) => {
|
||||
this.isLoading = false;
|
||||
const successCount = Object.keys(result).filter((domainName) =>
|
||||
result[domainName].responseCode.toString().startsWith('1')
|
||||
).length;
|
||||
const failureCount = Object.keys(result).length - successCount;
|
||||
this.dialog.open(ResponseDialogComponent, {
|
||||
data: {
|
||||
title: 'Domain Deletion Results',
|
||||
content: `Successfully deleted - ${successCount} domain(s)<br/>Failed to delete - ${failureCount} domain(s)<br/>${
|
||||
failureCount
|
||||
? 'Some domains could not be deleted due to ongoing processes or server errors. '
|
||||
: ''
|
||||
}Please check the table for more information.`,
|
||||
},
|
||||
});
|
||||
this.selection.clear();
|
||||
this.operationResult = result;
|
||||
this.reloadData();
|
||||
},
|
||||
error: (err: HttpErrorResponse) =>
|
||||
this._snackBar.open(err.error || err.message),
|
||||
});
|
||||
}
|
||||
|
||||
deleteSelectedDomains() {
|
||||
const dialogRef = this.dialog.open(ReasonDialogComponent, {
|
||||
data: {
|
||||
operation: 'deleting',
|
||||
},
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(
|
||||
take(1),
|
||||
filter((reason) => !!reason)
|
||||
)
|
||||
.subscribe(this.sendDeleteRequest.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ export class DomainListService {
|
||||
private backendService: BackendService,
|
||||
private registrarService: RegistrarService
|
||||
) {}
|
||||
|
||||
retrieveDomains(
|
||||
pageNumber?: number,
|
||||
resultsPerPage?: number,
|
||||
@@ -71,4 +70,13 @@ export class DomainListService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
deleteDomains(domains: Domain[], reason: string, registrarId: string) {
|
||||
return this.backendService.bulkDomainAction(
|
||||
domains.map((d) => d.domainName),
|
||||
reason,
|
||||
'DELETE',
|
||||
registrarId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(focus)="onFocus()"
|
||||
[matAutocomplete]="auto"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
autoActiveFirstOption
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum RESTRICTED_ELEMENTS {
|
||||
REGISTRAR_ELEMENT,
|
||||
OTE,
|
||||
USERS,
|
||||
BULK_DELETE,
|
||||
}
|
||||
|
||||
export const DISABLED_ELEMENTS_PER_ROLE = {
|
||||
@@ -26,6 +27,7 @@ export const DISABLED_ELEMENTS_PER_ROLE = {
|
||||
RESTRICTED_ELEMENTS.REGISTRAR_ELEMENT,
|
||||
RESTRICTED_ELEMENTS.OTE,
|
||||
RESTRICTED_ELEMENTS.USERS,
|
||||
RESTRICTED_ELEMENTS.BULK_DELETE,
|
||||
],
|
||||
SUPPORT_LEAD: [RESTRICTED_ELEMENTS.USERS],
|
||||
SUPPORT_AGENT: [RESTRICTED_ELEMENTS.USERS],
|
||||
|
||||
@@ -180,6 +180,23 @@ export class BackendService {
|
||||
.pipe(catchError((err) => this.errorCatcher<any>(err)));
|
||||
}
|
||||
|
||||
bulkDomainAction(
|
||||
domainNames: string[],
|
||||
reason: string,
|
||||
bulkDomainAction: string,
|
||||
registrarId: string
|
||||
) {
|
||||
return this.http
|
||||
.post<any>(
|
||||
`/console-api/bulk-domain?registrarId=${registrarId}&bulkDomainAction=${bulkDomainAction}`,
|
||||
{
|
||||
domainList: domainNames,
|
||||
reason,
|
||||
}
|
||||
)
|
||||
.pipe(catchError((err) => this.errorCatcher<any>(err)));
|
||||
}
|
||||
|
||||
updateUser(registrarId: string, updatedUser: User): Observable<any> {
|
||||
return this.http
|
||||
.put<User>(`/console-api/users?registrarId=${registrarId}`, updatedUser)
|
||||
|
||||
@@ -82,11 +82,6 @@ $typographyConfig: mat.m2-define-typography-config(
|
||||
),
|
||||
);
|
||||
|
||||
// Access and define a class with secondary color exposed
|
||||
.secondary-text {
|
||||
color: $secondary-color;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -115,6 +110,15 @@ mat-row:hover {
|
||||
--mat-sidenav-container-width: 280px;
|
||||
}
|
||||
|
||||
// Access and define a class with secondary color exposed
|
||||
.secondary-text {
|
||||
color: $secondary-color;
|
||||
}
|
||||
|
||||
.primary-text {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
$theme: mat.define-theme(
|
||||
(
|
||||
color: (
|
||||
|
||||
Reference in New Issue
Block a user