]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Change deletion link to modal only
authorStephan Müller <smueller@suse.com>
Mon, 16 Apr 2018 11:44:38 +0000 (13:44 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 24 Apr 2018 11:57:43 +0000 (13:57 +0200)
Due to CSS problems the link solution wasn't the best way, now it will
represent only the modal content. The downside of this solution is that
it put's the burden on the developer to use it the right way and import
a view things to get it working. But on the upside CSS styles will work
as expected.

The unit test example was updated accordingly this way it should be easy
to understand how it can be implemented the right way.

Signed-off-by: Stephan Müller <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts [new file with mode: 0644]

index cc3dd5bbfebe3c6f9e81b6182de8a3484d6e910f..63840d6f2bb9e75bac465b2e74b8735aaf1a73de 100644 (file)
@@ -9,7 +9,7 @@ import { PipesModule } from '../pipes/pipes.module';
 import {
   DeleteConfirmationComponent
 } from './delete-confirmation-modal/delete-confirmation-modal.component';
-import { DeletionLinkComponent } from './deletion-link/deletion-link.component';
+import { DeletionModalComponent } from './deletion-modal/deletion-modal.component';
 import { HelperComponent } from './helper/helper.component';
 import { ModalComponent } from './modal/modal.component';
 import { SparklineComponent } from './sparkline/sparkline.component';
@@ -38,7 +38,10 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     UsageBarComponent,
     DeleteConfirmationComponent,
     ModalComponent,
-    DeletionLinkComponent
+    DeletionModalComponent
+  ],
+  entryComponents: [
+    DeletionModalComponent
   ],
   providers: [],
   exports: [
@@ -52,7 +55,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
   entryComponents: [
     DeleteConfirmationComponent,
     ModalComponent,
-    DeletionLinkComponent
+    DeletionModalComponent
   ]
 })
 export class ComponentsModule { }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.html
deleted file mode 100644 (file)
index 9910fe9..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-<a (click)="showModal(deletionModal)">
-  <i class="fa fa-fw fa-trash-o"></i>
-  <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
-</a>
-
-<ng-template #deletionModal>
-  <cd-modal #modal
-            [modalRef]="bsModalRef">
-    <ng-container class="modal-title">
-      <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
-    </ng-container>
-
-    <ng-container class="modal-content">
-      <ng-container *ngTemplateOutlet="deletionContent"></ng-container>
-    </ng-container>
-  </cd-modal>
-</ng-template>
-
-<ng-template #deletionContent>
-  <form name="deletionForm"
-        #formDir="ngForm"
-        (submit)="delete()"
-        [formGroup]="deletionForm"
-        novalidate>
-    <div class="modal-body">
-      <ng-template *ngTemplateOutlet="deletionDescription"></ng-template>
-      <p>
-        <ng-container i18n>
-          To confirm the deletion, enter
-        </ng-container>
-        <kbd>{{ pattern }}</kbd>
-        <ng-container i18n>
-          and click on
-        </ng-container>
-        <kbd>
-          <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
-        </kbd>.
-      </p>
-      <div class="form-group"
-           [ngClass]="{'has-error': invalidControl(formDir.submitted)}">
-        <input type="text"
-               class="form-control"
-               name="confirmation"
-               id="confirmation"
-               [placeholder]="pattern"
-               autocomplete="off"
-               (keyup)="updateConfirmation($event)"
-               formControlName="confirmation"
-               autofocus>
-        <span class="help-block"
-              *ngIf="invalidControl(formDir.submitted,'required')"
-              i18n>
-          This field is required.
-        </span>
-        <span class="help-block"
-              *ngIf="invalidControl(formDir.submitted, 'pattern')">
-          '{{ confirmation.value }}'
-          <span i18n>doesn't match</span>
-          '{{ pattern }}'.
-        </span>
-      </div>
-    </div>
-    <div class="modal-footer">
-      <cd-submit-button #submitButton
-                        [form]="deletionForm"
-                        (submitAction)="deletionCall()">
-        <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
-      </cd-submit-button>
-      <button class="btn btn-link btn-sm"
-              (click)="hideModal()"
-              i18n>
-        Cancel
-      </button>
-    </div>
-  </form>
-</ng-template>
-
-<ng-template #deletionHeading>
-  <ng-container i18n>
-    Delete
-  </ng-container>
-  {{ metaType }}
-</ng-template>
-
-<ng-template #deletionDescription>
-  <ng-content></ng-content>
-</ng-template>
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.spec.ts
deleted file mode 100644 (file)
index 9ee3f60..0000000
+++ /dev/null
@@ -1,210 +0,0 @@
-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 { DeletionLinkComponent } from './deletion-link.component';
-
-@Component({
-  template: `
-    <cd-deletion-link #ctrlDeleteButton
-                      metaType="Controller delete handling"
-                      pattern="ctrl-test"
-                      (toggleDeletion)="fakeDeleteController()">
-      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.
-    </cd-deletion-link>
-    <cd-deletion-link #modalDeleteButton
-                      metaType="Modal delete handling"
-                      [deletionObserver]="fakeDelete()"
-                      pattern="modal-test">
-      The spinner is handled by the modal if your given deletion function returns a Observable.
-    </cd-deletion-link>
-  `
-})
-class MockComponent {
-  @ViewChild('ctrlDeleteButton') ctrlDeleteButton: DeletionLinkComponent;
-  @ViewChild('modalDeleteButton') modalDeleteButton: DeletionLinkComponent;
-  someData = [1, 2, 3, 4, 5];
-  finished: number[];
-
-  finish() {
-    this.finished = [6, 7, 8, 9];
-  }
-
-  fakeDelete() {
-    return (): Observable<any> => {
-      return new Observable((observer: Subscriber<any>) => {
-        Observable.timer(100).subscribe(() => {
-          observer.next(this.finish());
-          observer.complete();
-        });
-      });
-    };
-  }
-
-  fakeDeleteController() {
-    Observable.timer(100).subscribe(() => {
-      this.finish();
-      this.ctrlDeleteButton.hideModal();
-    });
-  }
-}
-
-describe('DeletionLinkComponent', () => {
-  let mockComponent: MockComponent;
-  let component: DeletionLinkComponent;
-  let fixture: ComponentFixture<MockComponent>;
-
-  beforeEach(async(() => {
-    TestBed.configureTestingModule({
-      declarations: [ MockComponent, DeletionLinkComponent, 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', <any>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', <any>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-link/deletion-link.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.ts
deleted file mode 100644 (file)
index cd79da2..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-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-link',
-  templateUrl: './deletion-link.component.html',
-  styleUrls: ['./deletion-link.component.scss']
-})
-export class DeletionLinkComponent implements OnInit {
-  @ViewChild(SubmitButtonComponent) submitButton: SubmitButtonComponent;
-  @Input() metaType: string;
-  @Input() pattern = 'yes';
-  @Input() deletionObserver: () => Observable<any>;
-  @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<any>) {
-    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;
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.html
new file mode 100644 (file)
index 0000000..59132d4
--- /dev/null
@@ -0,0 +1,73 @@
+<cd-modal #modal
+          [modalRef]="modalRef">
+  <ng-container class="modal-title">
+    <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
+  </ng-container>
+
+  <ng-container class="modal-content">
+    <form name="deletionForm"
+          #formDir="ngForm"
+          (submit)="delete()"
+          [formGroup]="deletionForm"
+          novalidate>
+      <div class="modal-body">
+        <ng-container *ngTemplateOutlet="description"></ng-container>
+        <p>
+          <ng-container i18n>
+            To confirm the deletion, enter
+          </ng-container>
+          <kbd>{{ pattern }}</kbd>
+          <ng-container i18n>
+            and click on
+          </ng-container>
+          <kbd>
+            <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
+          </kbd>.
+        </p>
+        <div class="form-group"
+             [ngClass]="{'has-error': invalidControl(formDir.submitted)}">
+          <input type="text"
+                 class="form-control"
+                 name="confirmation"
+                 id="confirmation"
+                 [placeholder]="pattern"
+                 [pattern]="pattern"
+                 autocomplete="off"
+                 (keyup)="updateConfirmation($event)"
+                 formControlName="confirmation"
+                 autofocus>
+          <span class="help-block"
+                *ngIf="invalidControl(formDir.submitted,'required')"
+                i18n>
+          This field is required.
+        </span>
+          <span class="help-block"
+                *ngIf="invalidControl(formDir.submitted, 'pattern')">
+          '{{ confirmation.value }}'
+          <span i18n>doesn't match</span>
+          '{{ pattern }}'.
+        </span>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <cd-submit-button #submitButton
+                          [form]="deletionForm"
+                          (submitAction)="deletionCall()">
+          <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
+        </cd-submit-button>
+        <button class="btn btn-link btn-sm"
+                (click)="hideModal()"
+                i18n>
+          Cancel
+        </button>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
+
+<ng-template #deletionHeading>
+  <ng-container i18n>
+    Delete
+  </ng-container>
+  {{ metaType }}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..bce74a2
--- /dev/null
@@ -0,0 +1,342 @@
+import { Component, NgModule, TemplateRef, ViewChild } from '@angular/core';
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef, BsModalService, 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 { DeletionModalComponent } from './deletion-modal.component';
+
+@NgModule({
+  entryComponents: [DeletionModalComponent]
+})
+export class MockModule {}
+
+@Component({
+  template: `
+    <button type="button"
+        class="btn btn-sm btn-primary"
+        (click)="openCtrlDriven()">
+      <i class="fa fa-fw fa-trash"></i>Deletion Ctrl-Test
+      <ng-template #ctrlDescription>
+        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.
+      </ng-template>
+    </button>
+
+    <button type="button"
+            class="btn btn-sm btn-primary"
+            (click)="openModalDriven()">
+      <i class="fa fa-fw fa-trash"></i>Deletion Modal-Test
+      <ng-template #modalDescription>
+        The spinner is handled by the modal if your given deletion function returns a Observable.
+      </ng-template>
+    </button>
+  `
+})
+class MockComponent {
+  @ViewChild('ctrlDescription') ctrlDescription: TemplateRef<any>;
+  @ViewChild('modalDescription') modalDescription: TemplateRef<any>;
+  someData = [1, 2, 3, 4, 5];
+  finished: number[];
+  ctrlRef: BsModalRef;
+  modalRef: BsModalRef;
+
+  // Normally private - public was needed for the tests
+  constructor(public modalService: BsModalService) {}
+
+  openCtrlDriven() {
+    this.ctrlRef = this.modalService.show(DeletionModalComponent);
+    this.ctrlRef.content.setUp({
+      metaType: 'Controller delete handling',
+      pattern: 'ctrl-test',
+      deletionMethod: this.fakeDeleteController.bind(this),
+      description: this.ctrlDescription,
+      modalRef: this.ctrlRef
+    });
+  }
+
+  openModalDriven() {
+    this.modalRef = this.modalService.show(DeletionModalComponent);
+    this.modalRef.content.setUp({
+      metaType: 'Modal delete handling',
+      pattern: 'modal-test',
+      deletionObserver: this.fakeDelete(),
+      description: this.modalDescription,
+      modalRef: this.modalRef
+    });
+  }
+
+  finish() {
+    this.finished = [6, 7, 8, 9];
+  }
+
+  fakeDelete() {
+    return (): Observable<any> => {
+      return new Observable((observer: Subscriber<any>) => {
+        Observable.timer(100).subscribe(() => {
+          observer.next(this.finish());
+          observer.complete();
+        });
+      });
+    };
+  }
+
+  fakeDeleteController() {
+    Observable.timer(100).subscribe(() => {
+      this.finish();
+      this.ctrlRef.hide();
+    });
+  }
+}
+
+describe('DeletionModalComponent', () => {
+  let mockComponent: MockComponent;
+  let component: DeletionModalComponent;
+  let mockFixture: ComponentFixture<MockComponent>;
+  let fixture: ComponentFixture<DeletionModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ MockComponent, DeletionModalComponent, ModalComponent,
+        SubmitButtonComponent],
+      imports: [ModalModule.forRoot(), ReactiveFormsModule, MockModule],
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    mockFixture = TestBed.createComponent(MockComponent);
+    mockComponent = mockFixture.componentInstance;
+    // Mocking the modals as a lot would be left over
+    spyOn(mockComponent.modalService, 'show').and.callFake(() => {
+      const ref = new BsModalRef();
+      fixture = TestBed.createComponent(DeletionModalComponent);
+      component = fixture.componentInstance;
+      fixture.detectChanges();
+      ref.content = component;
+      return ref;
+    });
+    mockComponent.openCtrlDriven();
+    mockFixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('setUp', () => {
+    const clearSetup = () => {
+      component.metaType = undefined;
+      component.pattern = 'yes';
+      component.deletionObserver = undefined;
+      component.description = undefined;
+      component.modalRef = undefined;
+    };
+
+    const expectSetup = (metaType, observer: boolean, method: boolean, pattern,
+                         template: boolean) => {
+      expect(component.modalRef).toBeTruthy();
+      expect(component.metaType).toBe(metaType);
+      expect(!!component.deletionObserver).toBe(observer);
+      expect(!!component.deletionMethod).toBe(method);
+      expect(component.pattern).toBe(pattern);
+      expect(!!component.description).toBe(template);
+    };
+
+    beforeEach(() => {
+      clearSetup();
+    });
+
+    it('should throw error if no modal reference is given', () => {
+      expect(() => component.setUp({
+        metaType: undefined,
+        modalRef: undefined
+      })).toThrowError('No modal reference');
+    });
+
+    it('should throw error if no meta type is given', () => {
+      expect(() => component.setUp({
+        metaType: undefined,
+        modalRef: mockComponent.ctrlRef
+      })).toThrowError('No meta type');
+    });
+
+    it('should throw error if no deletion method is given', () => {
+      expect(() => component.setUp({
+        metaType: 'Sth',
+        modalRef: mockComponent.ctrlRef
+      })).toThrowError('No deletion method');
+    });
+
+    it('should throw no errors if metaType, modalRef and a deletion method were given',
+        () => {
+          component.setUp({
+            metaType: 'Observer',
+            modalRef: mockComponent.ctrlRef,
+            deletionObserver: mockComponent.fakeDelete()
+          });
+          expectSetup('Observer', true, false, 'yes', false);
+          clearSetup();
+          component.setUp({
+            metaType: 'Controller',
+            modalRef: mockComponent.ctrlRef,
+            deletionMethod: mockComponent.fakeDeleteController
+          });
+          expectSetup('Controller', false, true, 'yes', false);
+        });
+
+    it('should test optional parameters - pattern and description',
+      () => {
+        component.setUp({
+          metaType: 'Pattern only',
+          modalRef: mockComponent.ctrlRef,
+          deletionObserver: mockComponent.fakeDelete(),
+          pattern: '{sth/!$_8()'
+        });
+        expectSetup('Pattern only', true, false, '{sth/!$_8()', false);
+        clearSetup();
+        component.setUp({
+          metaType: 'Description only',
+          modalRef: mockComponent.ctrlRef,
+          deletionObserver: mockComponent.fakeDelete(),
+          description: mockComponent.modalDescription
+        });
+        expectSetup('Description only', true, false, 'yes', true);
+        clearSetup();
+        component.setUp({
+          metaType: 'Description and pattern',
+          modalRef: mockComponent.ctrlRef,
+          deletionObserver: mockComponent.fakeDelete(),
+          description: mockComponent.modalDescription,
+          pattern: '{sth/!$_8()'
+        });
+        expectSetup('Description and pattern', true, false, '{sth/!$_8()', true);
+      });
+  });
+
+  it('should test if the ctrl driven mock is set correctly through mock component', () => {
+    expect(component.metaType).toBe('Controller delete handling');
+    expect(component.pattern).toBe('ctrl-test');
+    expect(component.description).toBeTruthy();
+    expect(component.modalRef).toBeTruthy();
+    expect(component.deletionMethod).toBeTruthy();
+    expect(component.deletionObserver).not.toBeTruthy();
+  });
+
+  it('should test if the modal driven mock is set correctly through mock component', () => {
+    mockComponent.openModalDriven();
+    expect(component.metaType).toBe('Modal delete handling');
+    expect(component.pattern).toBe('modal-test');
+    expect(component.description).toBeTruthy();
+    expect(component.modalRef).toBeTruthy();
+    expect(component.deletionObserver).toBeTruthy();
+    expect(component.deletionMethod).not.toBeTruthy();
+  });
+
+  describe('component functions', () => {
+    const changeValue = (value) => {
+      component.confirmation.setValue(value);
+      component.confirmation.markAsDirty();
+      component.confirmation.updateValueAndValidity();
+      fixture.detectChanges();
+    };
+
+    it('should test hideModal', () => {
+      expect(component.modalRef).toBeTruthy();
+      expect(component.hideModal).toBeTruthy();
+      spyOn(component.modalRef, 'hide').and.callThrough();
+      expect(component.modalRef.hide).not.toHaveBeenCalled();
+      component.hideModal();
+      expect(component.modalRef.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, 'stopLoadingSpinner').and.callThrough();
+        spyOn(component, 'hideModal').and.callThrough();
+      });
+
+      describe('Controller driven', () => {
+        beforeEach(() => {
+          spyOn(component, 'deletionMethod').and.callThrough();
+          spyOn(mockComponent.ctrlRef, 'hide').and.callThrough();
+        });
+
+        it('should test fake deletion that closes modal', <any>fakeAsync(() => {
+          // Before deletionCall
+          expect(component.deletionMethod).not.toHaveBeenCalled();
+          // During deletionCall
+          component.deletionCall();
+          expect(component.stopLoadingSpinner).not.toHaveBeenCalled();
+          expect(component.hideModal).not.toHaveBeenCalled();
+          expect(mockComponent.ctrlRef.hide).not.toHaveBeenCalled();
+          expect(component.deletionMethod).toHaveBeenCalled();
+          expect(mockComponent.finished).toBe(undefined);
+          // After deletionCall
+          tick(2000);
+          expect(component.hideModal).not.toHaveBeenCalled();
+          expect(mockComponent.ctrlRef.hide).toHaveBeenCalled();
+          expect(mockComponent.finished).toEqual([6, 7, 8, 9]);
+        }));
+      });
+
+      describe('Modal driven', () => {
+        beforeEach(() => {
+          mockComponent.openModalDriven();
+          spyOn(mockComponent.modalRef, 'hide').and.callThrough();
+          spyOn(component, 'stopLoadingSpinner').and.callThrough();
+          spyOn(component, 'hideModal').and.callThrough();
+          spyOn(mockComponent, 'fakeDelete').and.callThrough();
+        });
+
+        it('should delete and close modal', <any>fakeAsync(() => {
+          // During deletionCall
+          component.deletionCall();
+          expect(mockComponent.finished).toBe(undefined);
+          expect(component.hideModal).not.toHaveBeenCalled();
+          expect(mockComponent.modalRef.hide).not.toHaveBeenCalled();
+          // After deletionCall
+          tick(2000);
+          expect(mockComponent.finished).toEqual([6, 7, 8, 9]);
+          expect(mockComponent.modalRef.hide).toHaveBeenCalled();
+          expect(component.stopLoadingSpinner).not.toHaveBeenCalled();
+          expect(component.hideModal).toHaveBeenCalled();
+        }));
+      });
+    });
+  });
+
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-modal/deletion-modal.component.ts
new file mode 100644 (file)
index 0000000..d27963c
--- /dev/null
@@ -0,0 +1,100 @@
+import {
+  Component, OnInit, TemplateRef, ViewChild
+} from '@angular/core';
+import { FormControl, FormGroup, 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-modal',
+  templateUrl: './deletion-modal.component.html',
+  styleUrls: ['./deletion-modal.component.scss']
+})
+export class DeletionModalComponent implements OnInit {
+  @ViewChild(SubmitButtonComponent) submitButton: SubmitButtonComponent;
+  description: TemplateRef<any>;
+  metaType: string;
+  pattern = 'yes';
+  deletionObserver: () => Observable<any>;
+  deletionMethod: Function;
+  modalRef: BsModalRef;
+
+  deletionForm: FormGroup;
+  confirmation: FormControl;
+
+  // Parameters are destructed here than assigned to specific types and marked as optional
+  setUp({modalRef, metaType, deletionMethod, pattern, deletionObserver, description}:
+          { modalRef: BsModalRef, metaType: string, deletionMethod?: Function, pattern?: string,
+            deletionObserver?: () => Observable<any>, description?: TemplateRef<any>}) {
+    if (!modalRef) {
+      throw new Error('No modal reference');
+    } else if (!metaType) {
+      throw new Error('No meta type');
+    } else if (!(deletionMethod || deletionObserver)) {
+      throw new Error('No deletion method');
+    }
+    this.metaType = metaType;
+    this.modalRef = modalRef;
+    this.deletionMethod = deletionMethod;
+    this.pattern = pattern || this.pattern;
+    this.deletionObserver = deletionObserver;
+    this.description = description;
+  }
+
+  ngOnInit() {
+    this.confirmation = new FormControl('', {
+      validators: [
+        Validators.required
+      ],
+      updateOn: 'blur'
+    });
+    this.deletionForm = new FormGroup({
+      confirmation: this.confirmation
+    });
+  }
+
+  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();
+  }
+
+  delete () {
+    this.submitButton.submit();
+  }
+
+  deletionCall() {
+    if (this.deletionObserver) {
+      this.deletionObserver().subscribe(
+        undefined,
+        this.stopLoadingSpinner.bind(this),
+        this.hideModal.bind(this)
+      );
+    } else {
+      this.deletionMethod();
+    }
+  }
+
+  hideModal() {
+    this.modalRef.hide();
+  }
+
+  stopLoadingSpinner() {
+    this.submitButton.loading = false;
+  }
+}