]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Adds reusable deletion dialog
authorStephan Müller <smueller@suse.com>
Wed, 14 Mar 2018 15:09:07 +0000 (16:09 +0100)
committerStephan Müller <smueller@suse.com>
Tue, 24 Apr 2018 11:57:43 +0000 (13:57 +0200)
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 <tmelo@suse.com>
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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/deletion-button/deletion-button.component.ts [new file with mode: 0644]

index 8f00efb6ff53bc19dded4c999a8facee0fb50177..c716d9a5befbf43a241d455c8b3cf200c668dc38 100644 (file)
@@ -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 (file)
index 0000000..8744e4b
--- /dev/null
@@ -0,0 +1,90 @@
+<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
new file mode 100644 (file)
index 0000000..e69de29
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 (file)
index 0000000..564a0e2
--- /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 { 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
new file mode 100644 (file)
index 0000000..407e411
--- /dev/null
@@ -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<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;
+  }
+}