Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add console bulk delete #2641

Merged
merged 3 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion console-webapp/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -92,6 +96,8 @@ export class SelectedRegistrarModule {}
TldsComponent,
WhoisComponent,
WhoisEditComponent,
ReasonDialogComponent,
ResponseDialogComponent,
],
bootstrap: [AppComponent],
imports: [
Expand Down
56 changes: 53 additions & 3 deletions console-webapp/src/app/domains/domainList.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,66 @@ <h1>No domains found</h1>
/>
</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">
Expand Down
32 changes: 32 additions & 0 deletions console-webapp/src/app/domains/domainList.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
181 changes: 178 additions & 3 deletions console-webapp/src/app/domains/domainList.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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>();
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
}
10 changes: 9 additions & 1 deletion console-webapp/src/app/domains/domainList.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export class DomainListService {
private backendService: BackendService,
private registrarService: RegistrarService
) {}

retrieveDomains(
pageNumber?: number,
resultsPerPage?: number,
Expand All @@ -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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[ngModelOptions]="{ standalone: true }"
(focus)="onFocus()"
[matAutocomplete]="auto"
spellcheck="false"
/>
<mat-autocomplete
autoActiveFirstOption
Expand Down
Loading
Loading