]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Changes deletion button to link
authorStephan Müller <smueller@suse.com>
Mon, 16 Apr 2018 08:57:31 +0000 (10:57 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 24 Apr 2018 11:57:43 +0000 (13:57 +0200)
This change was made because a link can be placed anywhere instead of a
button element.

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-button/deletion-button.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.ts [new file with mode: 0644]

index c716d9a5befbf43a241d455c8b3cf200c668dc38..cc3dd5bbfebe3c6f9e81b6182de8a3484d6e910f 100644 (file)
@@ -9,7 +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 { DeletionLinkComponent } from './deletion-link/deletion-link.component';
 import { HelperComponent } from './helper/helper.component';
 import { ModalComponent } from './modal/modal.component';
 import { SparklineComponent } from './sparkline/sparkline.component';
@@ -38,7 +38,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     UsageBarComponent,
     DeleteConfirmationComponent,
     ModalComponent,
-    DeletionButtonComponent
+    DeletionLinkComponent
   ],
   providers: [],
   exports: [
@@ -52,7 +52,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
   entryComponents: [
     DeleteConfirmationComponent,
     ModalComponent,
-    DeletionButtonComponent
+    DeletionLinkComponent
   ]
 })
 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
deleted file mode 100644 (file)
index 8744e4b..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-<button type="button"
-        [class]="btnClasses"
-        (click)="showModal(deletionModal)">
-  <i class="fa fa-fw fa-trash-o"></i>
-  <ng-container *ngTemplateOutlet="deletionHeading"></ng-container>
-</button>
-
-<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-button/deletion-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
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
deleted file mode 100644 (file)
index 564a0e2..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 { DeletionButtonComponent } from './deletion-button.component';
-
-@Component({
-  template: `
-    <cd-deletion-button #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-button>
-    <cd-deletion-button #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-button>
-  `
-})
-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<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('DeletionButtonComponent', () => {
-  let mockComponent: MockComponent;
-  let component: DeletionButtonComponent;
-  let fixture: ComponentFixture<MockComponent>;
-
-  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', <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-button/deletion-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts
deleted file mode 100644 (file)
index 407e411..0000000
+++ /dev/null
@@ -1,88 +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-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<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-link/deletion-link.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-link/deletion-link.component.html
new file mode 100644 (file)
index 0000000..9910fe9
--- /dev/null
@@ -0,0 +1,88 @@
+<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
new file mode 100644 (file)
index 0000000..e69de29
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
new file mode 100644 (file)
index 0000000..9ee3f60
--- /dev/null
@@ -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 { 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
new file mode 100644 (file)
index 0000000..cd79da2
--- /dev/null
@@ -0,0 +1,87 @@
+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;
+  }
+}