Skip to content

Commit e3c386a

Browse files
authored
Add console bulk delete (#2641)
* Add bulk actions to console * Add console bulk delete * Add console bulk delete
1 parent 799f044 commit e3c386a

22 files changed

+359
-46
lines changed

console-webapp/src/app/app.module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import { BackendService } from './shared/services/backend.service';
2626
import { provideHttpClient } from '@angular/common/http';
2727
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
2828
import { BillingInfoComponent } from './billingInfo/billingInfo.component';
29-
import { DomainListComponent } from './domains/domainList.component';
29+
import {
30+
DomainListComponent,
31+
ReasonDialogComponent,
32+
ResponseDialogComponent,
33+
} from './domains/domainList.component';
3034
import { RegistryLockComponent } from './domains/registryLock.component';
3135
import { HeaderComponent } from './header/header.component';
3236
import { HomeComponent } from './home/home.component';
@@ -92,6 +96,8 @@ export class SelectedRegistrarModule {}
9296
TldsComponent,
9397
WhoisComponent,
9498
WhoisEditComponent,
99+
ReasonDialogComponent,
100+
ResponseDialogComponent,
95101
],
96102
bootstrap: [AppComponent],
97103
imports: [

console-webapp/src/app/domains/domainList.component.html

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,66 @@ <h1>No domains found</h1>
6565
/>
6666
</mat-form-field>
6767

68+
<div
69+
class="console-app__domains-selection"
70+
[elementId]="getElementIdForBulkDelete()"
71+
[ngClass]="{ active: selection.hasValue() }"
72+
>
73+
<div class="console-app__domains-selection-text">
74+
{{ selection.selected.length }} Selected
75+
</div>
76+
<div class="console-app__domains-selection-actions">
77+
<button
78+
mat-flat-button
79+
aria-label="Delete Selected Domains"
80+
(click)="deleteSelectedDomains()"
81+
>
82+
Delete Selected Domains
83+
</button>
84+
</div>
85+
</div>
86+
6887
<mat-table
6988
[dataSource]="dataSource"
7089
class="mat-elevation-z0"
7190
class="console-app__domains-table"
7291
>
92+
<!-- Checkbox Column -->
93+
<ng-container matColumnDef="select">
94+
<mat-header-cell *matHeaderCellDef>
95+
<mat-checkbox
96+
(change)="$event ? toggleAllRows() : null"
97+
[checked]="selection.hasValue() && isAllSelected"
98+
[indeterminate]="selection.hasValue() && !isAllSelected"
99+
[aria-label]="checkboxLabel()"
100+
[elementId]="getElementIdForBulkDelete()"
101+
>
102+
</mat-checkbox>
103+
</mat-header-cell>
104+
<mat-cell *matCellDef="let row">
105+
<mat-checkbox
106+
(click)="$event.stopPropagation()"
107+
(change)="$event ? selection.toggle(row) : null"
108+
[checked]="selection.isSelected(row)"
109+
[aria-label]="checkboxLabel(row)"
110+
[elementId]="getElementIdForBulkDelete()"
111+
>
112+
</mat-checkbox>
113+
</mat-cell>
114+
</ng-container>
115+
73116
<ng-container matColumnDef="domainName">
74117
<mat-header-cell *matHeaderCellDef>Domain Name</mat-header-cell>
75-
<mat-cell *matCellDef="let element">{{
76-
element.domainName
77-
}}</mat-cell>
118+
<mat-cell *matCellDef="let element">
119+
<mat-icon
120+
*ngIf="getOperationMessage(element.domainName)"
121+
[matTooltip]="getOperationMessage(element.domainName)"
122+
matTooltipPosition="above"
123+
class="primary-text"
124+
>info</mat-icon
125+
>
126+
<span>{{ element.domainName }}</span>
127+
</mat-cell>
78128
</ng-container>
79129

80130
<ng-container matColumnDef="creationTime">

console-webapp/src/app/domains/domainList.component.scss

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@
1212
}
1313
}
1414

15+
&__domains-selection {
16+
height: 60px;
17+
max-height: 0;
18+
transition: max-height 0.2s linear;
19+
display: flex;
20+
align-items: center;
21+
overflow: hidden;
22+
gap: 20px;
23+
&-text {
24+
font-weight: bold;
25+
}
26+
&.active {
27+
max-height: 60px;
28+
}
29+
}
30+
1531
&-domains__download {
1632
position: absolute;
1733
top: -55px;
@@ -41,6 +57,22 @@
4157
overflow: hidden;
4258
word-break: break-word;
4359
}
60+
.mat-column-select {
61+
max-width: 60px;
62+
padding-left: 15px;
63+
}
64+
.mat-column-domainName {
65+
position: relative;
66+
padding-left: 25px;
67+
mat-icon {
68+
position: absolute;
69+
left: 0;
70+
}
71+
}
72+
mat-cell:has([style*="display: none"]),
73+
mat-header-cell:has([style*="display: none"]) {
74+
display: none;
75+
}
4476
}
4577

4678
&__domains-spinner {

console-webapp/src/app/domains/domainList.component.ts

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,91 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import { SelectionModel } from '@angular/cdk/collections';
1516
import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http';
16-
import { Component, ViewChild, effect } from '@angular/core';
17+
import { Component, ViewChild, effect, Inject } from '@angular/core';
1718
import { MatPaginator, PageEvent } from '@angular/material/paginator';
1819
import { MatSnackBar } from '@angular/material/snack-bar';
1920
import { MatTableDataSource } from '@angular/material/table';
20-
import { Subject, debounceTime } from 'rxjs';
21+
import { Subject, debounceTime, take, filter } from 'rxjs';
2122
import { RegistrarService } from '../registrar/registrar.service';
2223
import { Domain, DomainListService } from './domainList.service';
2324
import { RegistryLockComponent } from './registryLock.component';
2425
import { RegistryLockService } from './registryLock.service';
26+
import {
27+
MAT_DIALOG_DATA,
28+
MatDialog,
29+
MatDialogRef,
30+
} from '@angular/material/dialog';
31+
import { RESTRICTED_ELEMENTS } from '../shared/directives/userLevelVisiblity.directive';
32+
33+
interface DomainResponse {
34+
message: string;
35+
responseCode: string;
36+
}
37+
38+
interface DomainData {
39+
[domain: string]: DomainResponse;
40+
}
41+
42+
@Component({
43+
selector: 'app-response-dialog',
44+
template: `
45+
<h2 mat-dialog-title>{{ data.title }}</h2>
46+
<mat-dialog-content [innerHTML]="data.content" />
47+
<mat-dialog-actions>
48+
<button mat-button (click)="onClose()">Close</button>
49+
</mat-dialog-actions>
50+
`,
51+
})
52+
export class ResponseDialogComponent {
53+
constructor(
54+
public dialogRef: MatDialogRef<ReasonDialogComponent>,
55+
@Inject(MAT_DIALOG_DATA)
56+
public data: { title: string; content: string }
57+
) {}
58+
59+
onClose(): void {
60+
this.dialogRef.close();
61+
}
62+
}
63+
64+
@Component({
65+
selector: 'app-reason-dialog',
66+
template: `
67+
<h2 mat-dialog-title>
68+
Please provide a reason for {{ data.operation }} the domain(s):
69+
</h2>
70+
<mat-dialog-content>
71+
<mat-form-field appearance="outline" style="width:100%">
72+
<textarea matInput [(ngModel)]="reason" rows="4"></textarea>
73+
</mat-form-field>
74+
</mat-dialog-content>
75+
<mat-dialog-actions>
76+
<button mat-button (click)="onCancel()">Cancel</button>
77+
<button mat-button color="warn" (click)="onDelete()" [disabled]="!reason">
78+
Delete
79+
</button>
80+
</mat-dialog-actions>
81+
`,
82+
})
83+
export class ReasonDialogComponent {
84+
reason: string = '';
85+
86+
constructor(
87+
public dialogRef: MatDialogRef<ReasonDialogComponent>,
88+
@Inject(MAT_DIALOG_DATA)
89+
public data: { operation: 'deleting' | 'suspending' }
90+
) {}
91+
92+
onDelete(): void {
93+
this.dialogRef.close(this.reason);
94+
}
95+
96+
onCancel(): void {
97+
this.dialogRef.close();
98+
}
99+
}
25100

26101
@Component({
27102
selector: 'app-domain-list',
@@ -31,8 +106,10 @@ import { RegistryLockService } from './registryLock.service';
31106
export class DomainListComponent {
32107
public static PATH = 'domain-list';
33108
private readonly DEBOUNCE_MS = 500;
109+
isAllSelected = false;
34110

35111
displayedColumns: string[] = [
112+
'select',
36113
'domainName',
37114
'creationTime',
38115
'registrationExpirationTime',
@@ -42,6 +119,7 @@ export class DomainListComponent {
42119
];
43120

44121
dataSource: MatTableDataSource<Domain> = new MatTableDataSource();
122+
selection = new SelectionModel<Domain>(true, [], undefined, this.isChecked());
45123
isLoading = true;
46124

47125
searchTermSubject = new Subject<string>();
@@ -51,13 +129,18 @@ export class DomainListComponent {
51129
resultsPerPage = 50;
52130
totalResults?: number = 0;
53131

132+
reason: string = '';
133+
134+
operationResult: DomainData | undefined;
135+
54136
@ViewChild(MatPaginator, { static: true }) paginator!: MatPaginator;
55137

56138
constructor(
57139
protected domainListService: DomainListService,
58140
protected registrarService: RegistrarService,
59141
protected registryLockService: RegistryLockService,
60-
private _snackBar: MatSnackBar
142+
private _snackBar: MatSnackBar,
143+
private dialog: MatDialog
61144
) {
62145
effect(() => {
63146
this.pageNumber = 0;
@@ -134,6 +217,98 @@ export class DomainListComponent {
134217
onPageChange(event: PageEvent) {
135218
this.pageNumber = event.pageIndex;
136219
this.resultsPerPage = event.pageSize;
220+
this.selection.clear();
137221
this.reloadData();
138222
}
223+
224+
toggleAllRows() {
225+
if (this.isAllSelected) {
226+
this.selection.clear();
227+
this.isAllSelected = false;
228+
return;
229+
}
230+
231+
this.selection.select(...this.dataSource.data);
232+
this.isAllSelected = true;
233+
}
234+
235+
checkboxLabel(row?: Domain): string {
236+
if (!row) {
237+
return `${this.isAllSelected ? 'deselect' : 'select'} all`;
238+
}
239+
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
240+
row.domainName
241+
}`;
242+
}
243+
244+
private isChecked(): ((o1: Domain, o2: Domain) => boolean) | undefined {
245+
return (o1: Domain, o2: Domain) => {
246+
if (!o1.domainName || !o2.domainName) {
247+
return false;
248+
}
249+
250+
return this.isAllSelected || o1.domainName === o2.domainName;
251+
};
252+
}
253+
254+
getElementIdForBulkDelete() {
255+
return RESTRICTED_ELEMENTS.BULK_DELETE;
256+
}
257+
258+
getOperationMessage(domain: string) {
259+
if (this.operationResult && this.operationResult[domain])
260+
return this.operationResult[domain].message;
261+
return '';
262+
}
263+
264+
sendDeleteRequest(reason: string) {
265+
this.isLoading = true;
266+
this.domainListService
267+
.deleteDomains(
268+
this.selection.selected,
269+
reason,
270+
this.registrarService.registrarId()
271+
)
272+
.pipe(take(1))
273+
.subscribe({
274+
next: (result: DomainData) => {
275+
this.isLoading = false;
276+
const successCount = Object.keys(result).filter((domainName) =>
277+
result[domainName].responseCode.toString().startsWith('1')
278+
).length;
279+
const failureCount = Object.keys(result).length - successCount;
280+
this.dialog.open(ResponseDialogComponent, {
281+
data: {
282+
title: 'Domain Deletion Results',
283+
content: `Successfully deleted - ${successCount} domain(s)<br/>Failed to delete - ${failureCount} domain(s)<br/>${
284+
failureCount
285+
? 'Some domains could not be deleted due to ongoing processes or server errors. '
286+
: ''
287+
}Please check the table for more information.`,
288+
},
289+
});
290+
this.selection.clear();
291+
this.operationResult = result;
292+
this.reloadData();
293+
},
294+
error: (err: HttpErrorResponse) =>
295+
this._snackBar.open(err.error || err.message),
296+
});
297+
}
298+
299+
deleteSelectedDomains() {
300+
const dialogRef = this.dialog.open(ReasonDialogComponent, {
301+
data: {
302+
operation: 'deleting',
303+
},
304+
});
305+
306+
dialogRef
307+
.afterClosed()
308+
.pipe(
309+
take(1),
310+
filter((reason) => !!reason)
311+
)
312+
.subscribe(this.sendDeleteRequest.bind(this));
313+
}
139314
}

console-webapp/src/app/domains/domainList.service.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ export class DomainListService {
4848
private backendService: BackendService,
4949
private registrarService: RegistrarService
5050
) {}
51-
5251
retrieveDomains(
5352
pageNumber?: number,
5453
resultsPerPage?: number,
@@ -71,4 +70,13 @@ export class DomainListService {
7170
})
7271
);
7372
}
73+
74+
deleteDomains(domains: Domain[], reason: string, registrarId: string) {
75+
return this.backendService.bulkDomainAction(
76+
domains.map((d) => d.domainName),
77+
reason,
78+
'DELETE',
79+
registrarId
80+
);
81+
}
7482
}

console-webapp/src/app/registrar/registrarSelector.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
[ngModelOptions]="{ standalone: true }"
1212
(focus)="onFocus()"
1313
[matAutocomplete]="auto"
14+
spellcheck="false"
1415
/>
1516
<mat-autocomplete
1617
autoActiveFirstOption

0 commit comments

Comments
 (0)