From 33e7533c3e32d1a9d3c125c2ae5659919eaa8387 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Wed, 14 Mar 2018 16:09:07 +0100 Subject: [PATCH] mgr/dashboard: Adds reusable deletion dialog MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit You can now simply use a deletion dialog without having to import a lot of different things from ngx-bootstrap. Its easy to extend the dialog by a detail description. Signed-off-by: Tiago Melo Signed-off-by: Stephan Müller --- .../shared/components/components.module.ts | 11 +- .../deletion-button.component.html | 90 ++++++++ .../deletion-button.component.scss | 0 .../deletion-button.component.spec.ts | 210 ++++++++++++++++++ .../deletion-button.component.ts | 88 ++++++++ 5 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 8f00efb6ff53b..c716d9a5befbf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { ReactiveFormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ChartsModule } from 'ng2-charts/ng2-charts'; import { AlertModule, ModalModule, PopoverModule, TooltipModule } from 'ngx-bootstrap'; @@ -9,6 +9,7 @@ import { PipesModule } from '../pipes/pipes.module'; import { DeleteConfirmationComponent } from './delete-confirmation-modal/delete-confirmation-modal.component'; +import { DeletionButtonComponent } from './deletion-button/deletion-button.component'; import { HelperComponent } from './helper/helper.component'; import { ModalComponent } from './modal/modal.component'; import { SparklineComponent } from './sparkline/sparkline.component'; @@ -19,6 +20,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; @NgModule({ imports: [ CommonModule, + FormsModule, + ReactiveFormsModule, AlertModule.forRoot(), PopoverModule.forRoot(), TooltipModule.forRoot(), @@ -34,7 +37,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; SubmitButtonComponent, UsageBarComponent, DeleteConfirmationComponent, - ModalComponent + ModalComponent, + DeletionButtonComponent ], providers: [], exports: [ @@ -47,7 +51,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; ], entryComponents: [ DeleteConfirmationComponent, - ModalComponent + ModalComponent, + DeletionButtonComponent ] }) export class ComponentsModule { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.html new file mode 100644 index 0000000000000..8744e4b345c7d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + +
+ + +
+
+ + + + Delete + + {{ metaType }} + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts new file mode 100644 index 0000000000000..564a0e2b9857e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts @@ -0,0 +1,210 @@ +import { Component, ViewChild } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormGroupDirective, ReactiveFormsModule } from '@angular/forms'; + +import { ModalModule } from 'ngx-bootstrap'; +import { Observable } from 'rxjs/Observable'; +import { Subscriber } from 'rxjs/Subscriber'; + +import { ModalComponent } from '../modal/modal.component'; +import { SubmitButtonComponent } from '../submit-button/submit-button.component'; +import { DeletionButtonComponent } from './deletion-button.component'; + +@Component({ + template: ` + + The spinner is handled by the controller if you have use the modal as ViewChild in order to + use it's functions to stop the spinner or close the dialog. + + + The spinner is handled by the modal if your given deletion function returns a Observable. + + ` +}) +class MockComponent { + @ViewChild('ctrlDeleteButton') ctrlDeleteButton: DeletionButtonComponent; + @ViewChild('modalDeleteButton') modalDeleteButton: DeletionButtonComponent; + someData = [1, 2, 3, 4, 5]; + finished: number[]; + + finish() { + this.finished = [6, 7, 8, 9]; + } + + fakeDelete() { + return (): Observable => { + return new Observable((observer: Subscriber) => { + Observable.timer(100).subscribe(() => { + observer.next(this.finish()); + observer.complete(); + }); + }); + }; + } + + fakeDeleteController() { + Observable.timer(100).subscribe(() => { + this.finish(); + this.ctrlDeleteButton.hideModal(); + }); + } +} + +describe('DeletionButtonComponent', () => { + let mockComponent: MockComponent; + let component: DeletionButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ MockComponent, DeletionButtonComponent, ModalComponent, + SubmitButtonComponent], + imports: [ModalModule.forRoot(), ReactiveFormsModule], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MockComponent); + mockComponent = fixture.componentInstance; + component = mockComponent.ctrlDeleteButton; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('component functions', () => { + + const mockShowModal = () => { + component.showModal(null); + }; + + const changeValue = (value) => { + component.confirmation.setValue(value); + component.confirmation.markAsDirty(); + component.confirmation.updateValueAndValidity(); + }; + + beforeEach(() => { + spyOn(component.modalService, 'show').and.returnValue({ + hide: () => true + }); + }); + + it('should test showModal', () => { + changeValue('something'); + expect(mockShowModal).toBeTruthy(); + expect(component.confirmation.value).toBe('something'); + expect(component.modalService.show).not.toHaveBeenCalled(); + mockShowModal(); + expect(component.modalService.show).toHaveBeenCalled(); + expect(component.confirmation.value).toBe(null); + expect(component.confirmation.pristine).toBe(true); + }); + + it('should test hideModal', () => { + expect(component.bsModalRef).not.toBeTruthy(); + mockShowModal(); + expect(component.bsModalRef).toBeTruthy(); + expect(component.hideModal).toBeTruthy(); + spyOn(component.bsModalRef, 'hide').and.stub(); + expect(component.bsModalRef.hide).not.toHaveBeenCalled(); + component.hideModal(); + expect(component.bsModalRef.hide).toHaveBeenCalled(); + }); + + describe('invalid control', () => { + + const testInvalidControl = (submitted: boolean, error: string, expected: boolean) => { + expect(component.invalidControl(submitted, error)).toBe(expected); + }; + + beforeEach(() => { + component.deletionForm.reset(); + }); + + it('should test empty values', () => { + expect(component.invalidControl).toBeTruthy(); + component.deletionForm.reset(); + testInvalidControl(false, undefined, false); + testInvalidControl(true, 'required', true); + component.deletionForm.reset(); + changeValue('let-me-pass'); + changeValue(''); + testInvalidControl(true, 'required', true); + }); + + it('should test pattern', () => { + changeValue('let-me-pass'); + testInvalidControl(false, 'pattern', true); + changeValue('ctrl-test'); + testInvalidControl(false, undefined, false); + testInvalidControl(true, undefined, false); + }); + }); + + describe('deletion call', () => { + beforeEach(() => { + spyOn(component.toggleDeletion, 'emit'); + spyOn(component, 'stopLoadingSpinner'); + spyOn(component, 'hideModal').and.stub(); + }); + + describe('Controller driven', () => { + beforeEach(() => { + mockShowModal(); + expect(component.toggleDeletion.emit).not.toHaveBeenCalled(); + expect(component.stopLoadingSpinner).not.toHaveBeenCalled(); + expect(component.hideModal).not.toHaveBeenCalled(); + }); + + it('should delete without doing anything but call emit', () => { + component.deletionCall(); + expect(component.stopLoadingSpinner).not.toHaveBeenCalled(); + expect(component.hideModal).not.toHaveBeenCalled(); + expect(component.toggleDeletion.emit).toHaveBeenCalled(); + }); + + it('should test fake deletion that closes modal', fakeAsync(() => { + mockComponent.fakeDeleteController(); + expect(component.hideModal).not.toHaveBeenCalled(); + expect(mockComponent.finished).toBe(undefined); + tick(2000); + expect(component.hideModal).toHaveBeenCalled(); + expect(mockComponent.finished).toEqual([6, 7, 8, 9]); + })); + }); + + describe('Modal driven', () => { + it('should delete and close modal', fakeAsync(() => { + component = mockComponent.modalDeleteButton; + mockShowModal(); + spyOn(component.toggleDeletion, 'emit'); + spyOn(component, 'stopLoadingSpinner'); + spyOn(component, 'hideModal').and.stub(); + spyOn(mockComponent, 'fakeDelete'); + + component.deletionCall(); + expect(mockComponent.finished).toBe(undefined); + expect(component.toggleDeletion.emit).not.toHaveBeenCalled(); + expect(component.hideModal).not.toHaveBeenCalled(); + + tick(2000); + expect(component.toggleDeletion.emit).not.toHaveBeenCalled(); + expect(component.stopLoadingSpinner).not.toHaveBeenCalled(); + expect(component.hideModal).toHaveBeenCalled(); + expect(mockComponent.finished).toEqual([6, 7, 8, 9]); + })); + }); + }); + }); + +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts new file mode 100644 index 0000000000000..407e411e92d7f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts @@ -0,0 +1,88 @@ +import { + Component, EventEmitter, Input, OnInit, Output, TemplateRef, ViewChild +} from '@angular/core'; +import { FormControl, FormGroup, FormGroupDirective, Validators } from '@angular/forms'; + +import { BsModalRef, BsModalService } from 'ngx-bootstrap'; +import { Observable } from 'rxjs/Observable'; + +import { SubmitButtonComponent } from '../submit-button/submit-button.component'; + +@Component({ + selector: 'cd-deletion-button', + templateUrl: './deletion-button.component.html', + styleUrls: ['./deletion-button.component.scss'] +}) +export class DeletionButtonComponent implements OnInit { + @ViewChild(SubmitButtonComponent) submitButton: SubmitButtonComponent; + @Input() metaType: string; + @Input() pattern = 'yes'; + @Input() btnClasses = 'btn btn-sm btn-primary'; + @Input() deletionObserver: () => Observable; + @Output() toggleDeletion = new EventEmitter(); + bsModalRef: BsModalRef; + deletionForm: FormGroup; + confirmation: FormControl; + delete: Function; + + constructor(public modalService: BsModalService) {} + + ngOnInit() { + this.confirmation = new FormControl('', { + validators: [ + Validators.required, + Validators.pattern(this.pattern) + ], + updateOn: 'blur' + }); + this.deletionForm = new FormGroup({ + confirmation: this.confirmation + }); + } + + showModal(template: TemplateRef) { + this.deletionForm.reset(); + this.bsModalRef = this.modalService.show(template); + this.delete = () => { + this.submitButton.submit(); + }; + } + + invalidControl(submitted: boolean, error?: string): boolean { + const control = this.confirmation; + return !!( + (submitted || control.dirty) && + control.invalid && + (error ? control.errors[error] : true) + ); + } + + updateConfirmation($e) { + if ($e.key !== 'Enter') { + return; + } + this.confirmation.setValue($e.target.value); + this.confirmation.markAsDirty(); + this.confirmation.updateValueAndValidity(); + } + + deletionCall() { + if (this.deletionObserver) { + this.deletionObserver().subscribe( + undefined, + () => this.stopLoadingSpinner(), + () => this.hideModal() + ); + } else { + this.toggleDeletion.emit(); + } + } + + hideModal() { + this.bsModalRef.hide(); + } + + stopLoadingSpinner() { + this.submitButton.loading = false; + } +} -- 2.39.5