]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add generic Select component
authorTiago Melo <tmelo@suse.com>
Tue, 15 Jan 2019 15:28:00 +0000 (15:28 +0000)
committerTiago Melo <tmelo@suse.com>
Tue, 5 Feb 2019 12:02:33 +0000 (12:02 +0000)
Signed-off-by: Tiago Melo <tmelo@suse.com>
17 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-messages.model.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-option.model.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts [new file with mode: 0644]

index 9e1a60764572b101259cebe71cf05e4db0d5edc3..f6036b6f83bf2b87677c21668b6a9081251f2712 100644 (file)
@@ -2,8 +2,8 @@ import { Validators } from '@angular/forms';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
 
-import { SelectBadgesMessages } from '../../../shared/components/select-badges/select-badges-messages.model';
-import { SelectBadgesOption } from '../../../shared/components/select-badges/select-badges-option.model';
+import { SelectMessages } from '../../../shared/components/select/select-messages.model';
+import { SelectOption } from '../../../shared/components/select/select-option.model';
 import { Pool } from '../pool';
 
 export class PoolFormData {
@@ -17,12 +17,12 @@ export class PoolFormData {
     this.applications = {
       selected: [],
       available: [
-        new SelectBadgesOption(false, 'cephfs', ''),
-        new SelectBadgesOption(false, 'rbd', ''),
-        new SelectBadgesOption(false, 'rgw', '')
+        new SelectOption(false, 'cephfs', ''),
+        new SelectOption(false, 'rbd', ''),
+        new SelectOption(false, 'rgw', '')
       ],
       validators: [Validators.pattern('[A-Za-z0-9_]+'), Validators.maxLength(128)],
-      messages: new SelectBadgesMessages(
+      messages: new SelectMessages(
         {
           empty: i18n('No applications added'),
           selectionLimit: {
index 91160ec98c788d2cd9603999ad8009b7dc5077b9..b672d371c3f70f77a70f11b3aa2f90dd06e40cfa 100644 (file)
@@ -380,7 +380,7 @@ describe('PoolFormComponent', () => {
       fixture.detectChanges();
       const selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
         .componentInstance;
-      const control = selectBadges.filter;
+      const control = selectBadges.cdSelect.filter;
       formHelper.expectValid(control);
       control.setValue('?');
       formHelper.expectError(control, 'pattern');
@@ -529,21 +529,21 @@ describe('PoolFormComponent', () => {
     let selectBadges: SelectBadgesComponent;
 
     const testAddApp = (app?: string, result?: string[]) => {
-      selectBadges.filter.setValue(app);
-      selectBadges.updateFilter();
-      selectBadges.selectOption();
+      selectBadges.cdSelect.filter.setValue(app);
+      selectBadges.cdSelect.updateFilter();
+      selectBadges.cdSelect.selectOption();
       expect(component.data.applications.selected).toEqual(result);
     };
 
     const testRemoveApp = (app: string, result: string[]) => {
-      selectBadges.removeItem(app);
+      selectBadges.cdSelect.removeItem(app);
       expect(component.data.applications.selected).toEqual(result);
     };
 
     const setCurrentApps = (apps: string[]) => {
       component.data.applications.selected = apps;
       fixture.detectChanges();
-      selectBadges.ngOnInit();
+      selectBadges.cdSelect.ngOnInit();
       return apps;
     };
 
index 28caa3f9dae7670ed691b1774f8aa1568878544f..3bc8f23892cc5f58236b41f8a1f73a8a009f785a 100644 (file)
@@ -1,6 +1,6 @@
-import { SelectBadgesOption } from '../../../shared/components/select-badges/select-badges-option.model';
+import { SelectOption } from '../../../shared/components/select/select-option.model';
 
-export class UserFormRoleModel implements SelectBadgesOption {
+export class UserFormRoleModel implements SelectOption {
   name: string;
   description: string;
   selected = false;
index 8d2204e25f62486ef631c7f82bbe17c7be673850..826b2de46bfe7e33dce36eb7ac428dd613ed0acd 100644 (file)
@@ -10,7 +10,7 @@ import { AuthService } from '../../../shared/api/auth.service';
 import { RoleService } from '../../../shared/api/role.service';
 import { UserService } from '../../../shared/api/user.service';
 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
-import { SelectBadgesMessages } from '../../../shared/components/select-badges/select-badges-messages.model';
+import { SelectMessages } from '../../../shared/components/select/select-messages.model';
 import { NotificationType } from '../../../shared/enum/notification-type.enum';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
 import { CdValidators } from '../../../shared/forms/cd-validators';
@@ -37,7 +37,7 @@ export class UserFormComponent implements OnInit {
   userFormMode = UserFormMode;
   mode: UserFormMode;
   allRoles: Array<UserFormRoleModel>;
-  messages: SelectBadgesMessages;
+  messages = new SelectMessages({ empty: 'There are no roles.' }, this.i18n);
 
   constructor(
     private authService: AuthService,
@@ -51,7 +51,7 @@ export class UserFormComponent implements OnInit {
     private i18n: I18n
   ) {
     this.createForm();
-    this.messages = new SelectBadgesMessages({ empty: 'There are no roles.' }, this.i18n);
+    this.messages = new SelectMessages({ empty: 'There are no roles.' }, this.i18n);
   }
 
   createForm() {
index 95107103096523bbbbfc48c25e5fc0091c3e57a0..02c54aa160c9e1cebc6c67c7c59fa62c76085b92 100644 (file)
@@ -21,6 +21,7 @@ import { LanguageSelectorComponent } from './language-selector/language-selector
 import { LoadingPanelComponent } from './loading-panel/loading-panel.component';
 import { ModalComponent } from './modal/modal.component';
 import { SelectBadgesComponent } from './select-badges/select-badges.component';
+import { SelectComponent } from './select/select.component';
 import { SparklineComponent } from './sparkline/sparkline.component';
 import { SubmitButtonComponent } from './submit-button/submit-button.component';
 import { UsageBarComponent } from './usage-bar/usage-bar.component';
@@ -57,7 +58,8 @@ import { WarningPanelComponent } from './warning-panel/warning-panel.component';
     ConfirmationModalComponent,
     WarningPanelComponent,
     LanguageSelectorComponent,
-    GrafanaComponent
+    GrafanaComponent,
+    SelectComponent
   ],
   providers: [],
   exports: [
@@ -73,7 +75,8 @@ import { WarningPanelComponent } from './warning-panel/warning-panel.component';
     ModalComponent,
     WarningPanelComponent,
     LanguageSelectorComponent,
-    GrafanaComponent
+    GrafanaComponent,
+    SelectComponent
   ],
   entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-messages.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-messages.model.ts
deleted file mode 100644 (file)
index c530641..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-import { I18n } from '@ngx-translate/i18n-polyfill';
-
-import * as _ from 'lodash';
-
-export class SelectBadgesMessages {
-  i18n: I18n;
-  empty: string;
-  selectionLimit: any;
-  customValidations = {};
-  filter: string;
-  add: string;
-
-  constructor(messages: {}, i18n: I18n) {
-    this.i18n = i18n;
-
-    this.empty = this.i18n('There are no items.');
-    this.selectionLimit = {
-      tooltip: this.i18n('Deselect item to select again'),
-      text: this.i18n('Selection limit reached')
-    };
-    this.filter = this.i18n('Filter tags');
-    this.add = this.i18n('Add badge'); // followed by " '{{filter.value}}'"
-
-    _.merge(this, messages);
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-option.model.ts
deleted file mode 100644 (file)
index 49f2933..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-export class SelectBadgesOption {
-  selected: boolean;
-  name: string;
-  description: string;
-
-  constructor(selected: boolean, name: string, description: string) {
-    this.selected = selected;
-    this.name = name;
-    this.description = description;
-  }
-}
index 45a3ebf2b853d0f07488ee6e0270eb0c8cba7d07..8d65bda260e03f2752c6d93a1a3ea952657194c7 100644 (file)
@@ -1,81 +1,19 @@
-<ng-template #popTemplate>
-  <form name="form"
-        #formDir="ngForm"
-        [formGroup]="form"
-        novalidate>
-    <div [ngClass]="{'has-error': form.showError('filter', formDir)}">
-      <input type="text"
-             formControlName="filter"
-             i18n-placeholder
-             [placeholder]="messages.filter"
-             (keyup)="$event.keyCode == 13 ? selectOption() : updateFilter()"
-             class="form-control text-center" />
-      <ng-container *ngFor="let error of Object.keys(messages.customValidations)">
-        <span class="help-block text-center"
-              *ngIf="form.showError('filter', formDir) && filter.hasError(error)">
-          {{ messages.customValidations[error] }}
-        </span>
-      </ng-container>
-    </div>
-  </form>
-  <div *ngFor="let option of filteredOptions"
-       class="select-menu-item"
-       [class.disabled]="data.length === selectionLimit && !option.selected"
-       (click)="triggerSelection(option)">
-    <div class="select-menu-item-icon">
-      <i class="fa fa-check"
-         aria-hidden="true"
-         *ngIf="option.selected"></i>
-      &nbsp;
-    </div>
-    <div class="select-menu-item-content">
-      {{ option.name }}
-      <ng-container *ngIf="option.description">
-        <br>
-        <small class="text-muted">
-          {{ option.description }}&nbsp;
-        </small>
-      </ng-container>
-    </div>
-  </div>
-  <div *ngIf="isCreatable()"
-       class="select-menu-item"
-       (click)="addCustomOption(filter.value)">
-    <div class="select-menu-item-icon">
-      <i class="fa fa-tag"
-         aria-hidden="true"></i>
-      &nbsp;
-    </div>
-    <div class="select-menu-item-content">
-      {{ messages.add }} '{{ filter.value }}'
-    </div>
-  </div>
-  <div class="has-warning"
-       *ngIf="data.length === selectionLimit">
-    <span class="help-block text-center text-warning"
-          [tooltip]="messages.selectionLimit.tooltip"
-          *ngIf="data.length === selectionLimit">
-      {{ messages.selectionLimit.text }}
-    </span>
-  </div>
-</ng-template>
-
-<a class="margin-right-sm select-menu-edit"
-   [popover]="popTemplate"
-   placement="bottom"
-   container="body"
-   outsideClick="true">
+<cd-select #cdSelect
+           [data]="data"
+           [options]="options"
+           [messages]="messages"
+           [selectionLimit]="selectionLimit"
+           [customBadges]="customBadges"
+           [customBadgeValidators]="customBadgeValidators"
+           elemClass="margin-right-sm select-menu-edit">
   <i class="fa fa-fw fa-pencil"></i>
-</a>
-<span class="text-muted"
-      *ngIf="data.length === 0">
-  {{ messages.empty }}
-</span>
+</cd-select>
+
 <span *ngFor="let dataItem of data">
   <span class="badge badge-pill badge-primary margin-right-sm">
     <span class="margin-right-sm">{{ dataItem }}</span>
     <a class="badge-remove"
-       (click)="removeItem(dataItem)">
+       (click)="cdSelect.removeItem(dataItem)">
       <i class="fa fa-times"
          aria-hidden="true"></i>
     </a>
index 74814b636db384fcb9d49b9f256f8451eb7b080a..d5f2a30d099ae3d6cb86ca1539359ef2f4a12607 100644 (file)
@@ -1,25 +1,8 @@
 @import '../../../../defaults';
 
-.select-menu-edit {
+::ng-deep .select-menu-edit {
   margin-left: -20px;
 }
-.select-menu-item {
-  display: block;
-  cursor: pointer;
-  border-bottom: 1px solid $color-transparent;
-  font-size: 12px;
-  &:hover {
-    background-color: $color-whitesmoke-gray;
-  }
-}
-.select-menu-item-icon {
-  float: left;
-  padding: 0.5em;
-  width: 3em;
-}
-.select-menu-item-content {
-  padding: 0.5em;
-}
 .badge-remove {
   color: $color-solid-white;
 }
index cc539353ee8dca48a2ebab395ef99e58b2ee9bf7..67221be4fe6e6409af93f2e08fead84526b7f6c4 100644 (file)
@@ -1,25 +1,21 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ReactiveFormsModule, Validators } from '@angular/forms';
 
+import { I18n } from '@ngx-translate/i18n-polyfill';
 import { PopoverModule } from 'ngx-bootstrap/popover';
 import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
-import { SelectBadgesOption } from './select-badges-option.model';
+import { SelectMessages } from '../select/select-messages.model';
+import { SelectComponent } from '../select/select.component';
 import { SelectBadgesComponent } from './select-badges.component';
 
 describe('SelectBadgesComponent', () => {
   let component: SelectBadgesComponent;
   let fixture: ComponentFixture<SelectBadgesComponent>;
 
-  const selectOption = (filter: string) => {
-    component.filter.setValue(filter);
-    component.updateFilter();
-    component.selectOption();
-  };
-
   configureTestBed({
-    declarations: [SelectBadgesComponent],
+    declarations: [SelectBadgesComponent, SelectComponent],
     imports: [PopoverModule.forRoot(), TooltipModule, ReactiveFormsModule],
     providers: i18nProviders
   });
@@ -28,252 +24,38 @@ describe('SelectBadgesComponent', () => {
     fixture = TestBed.createComponent(SelectBadgesComponent);
     component = fixture.componentInstance;
     fixture.detectChanges();
-    component.options = [
-      { name: 'option1', description: '', selected: false },
-      { name: 'option2', description: '', selected: false },
-      { name: 'option3', description: '', selected: false }
-    ];
   });
 
   it('should create', () => {
     expect(component).toBeTruthy();
   });
 
-  it('should add item', () => {
-    component.data = [];
-    component.triggerSelection(component.options[1]);
-    expect(component.data).toEqual(['option2']);
-  });
-
-  it('should update selected', () => {
-    component.data = ['option2'];
-    component.ngOnChanges();
-    expect(component.options[0].selected).toBe(false);
-    expect(component.options[1].selected).toBe(true);
-  });
-
-  it('should remove item', () => {
-    component.options.map((option) => {
-      option.selected = true;
-      return option;
-    });
-    component.data = ['option1', 'option2', 'option3'];
-    component.removeItem('option1');
-    expect(component.data).toEqual(['option2', 'option3']);
-  });
-
-  it('should not remove item that is not selected', () => {
-    component.options[0].selected = true;
-    component.data = ['option1'];
-    component.removeItem('option2');
-    expect(component.data).toEqual(['option1']);
-  });
-
-  describe('filter values', () => {
-    beforeEach(() => {
-      component.ngOnInit();
-    });
-
-    it('shows all options with no value set', () => {
-      expect(component.filteredOptions).toEqual(component.options);
-    });
-
-    it('shows one option that it filtered for', () => {
-      component.filter.setValue('2');
-      component.updateFilter();
-      expect(component.filteredOptions).toEqual([component.options[1]]);
-    });
-
-    it('shows all options after selecting something', () => {
-      component.filter.setValue('2');
-      component.updateFilter();
-      component.selectOption();
-      expect(component.filteredOptions).toEqual(component.options);
-    });
-
-    it('is not able to create by default with no value set', () => {
-      component.updateFilter();
-      expect(component.isCreatable()).toBeFalsy();
-    });
-
-    it('is not able to create by default with a value set', () => {
-      component.filter.setValue('2');
-      component.updateFilter();
-      expect(component.isCreatable()).toBeFalsy();
-    });
-  });
-
-  describe('automatically add selected options if not in options array', () => {
-    beforeEach(() => {
-      component.data = ['option1', 'option4'];
-      expect(component.options.length).toBe(3);
-    });
-
-    const expectedResult = () => {
-      expect(component.options.length).toBe(4);
-      expect(component.options[3]).toEqual(new SelectBadgesOption(true, 'option4', ''));
-    };
-
-    it('with no extra settings', () => {
-      component.ngOnInit();
-      expectedResult();
-    });
-
-    it('with custom badges', () => {
-      component.customBadges = true;
-      component.ngOnInit();
-      expectedResult();
-    });
-
-    it('with limit higher than selected', () => {
-      component.selectionLimit = 3;
-      component.ngOnInit();
-      expectedResult();
-    });
-
-    it('with limit equal to selected', () => {
-      component.selectionLimit = 2;
-      component.ngOnInit();
-      expectedResult();
-    });
-
-    it('with limit lower than selected', () => {
-      component.selectionLimit = 1;
-      component.ngOnInit();
-      expectedResult();
-    });
-  });
-
-  describe('sorted array and options', () => {
-    beforeEach(() => {
-      component.customBadges = true;
-      component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
-      component.data = ['c', 'b'];
-      component.options = [
-        new SelectBadgesOption(true, 'd', ''),
-        new SelectBadgesOption(true, 'a', '')
-      ];
-      component.ngOnInit();
-    });
-
-    it('has a sorted selection', () => {
-      expect(component.data).toEqual(['a', 'b', 'c', 'd']);
-    });
-
-    it('has a sorted options', () => {
-      const sortedOptions = [
-        new SelectBadgesOption(true, 'a', ''),
-        new SelectBadgesOption(true, 'b', ''),
-        new SelectBadgesOption(true, 'c', ''),
-        new SelectBadgesOption(true, 'd', '')
-      ];
-      expect(component.options).toEqual(sortedOptions);
-    });
-
-    it('has a sorted selection after adding an item', () => {
-      selectOption('block');
-      expect(component.data).toEqual(['a', 'b', 'block', 'c', 'd']);
-    });
-
-    it('has a sorted options after adding an item', () => {
-      selectOption('block');
-      const sortedOptions = [
-        new SelectBadgesOption(true, 'a', ''),
-        new SelectBadgesOption(true, 'b', ''),
-        new SelectBadgesOption(true, 'block', ''),
-        new SelectBadgesOption(true, 'c', ''),
-        new SelectBadgesOption(true, 'd', '')
-      ];
-      expect(component.options).toEqual(sortedOptions);
-    });
-  });
-
-  describe('with custom options', () => {
-    beforeEach(() => {
-      component.customBadges = true;
-      component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
-      component.ngOnInit();
-    });
-
-    it('is not able to create with no value set', () => {
-      component.updateFilter();
-      expect(component.isCreatable()).toBeFalsy();
-    });
-
-    it('is able to create with a valid value set', () => {
-      component.filter.setValue('2');
-      component.updateFilter();
-      expect(component.isCreatable()).toBeTruthy();
-    });
-
-    it('is not able to create with a value set that already exist', () => {
-      component.filter.setValue('option2');
-      component.updateFilter();
-      expect(component.isCreatable()).toBeFalsy();
-    });
-
-    it('adds custom option', () => {
-      selectOption('customOption');
-      expect(component.options[0]).toEqual({
-        name: 'customOption',
-        description: '',
-        selected: true
-      });
-      expect(component.options.length).toBe(4);
-      expect(component.data).toEqual(['customOption']);
-    });
-
-    it('will not add an option that did not pass the validation', () => {
-      selectOption(' this does not pass ');
-      expect(component.options.length).toBe(3);
-      expect(component.data).toEqual([]);
-      expect(component.filter.invalid).toBeTruthy();
-    });
-
-    it('removes custom item selection by name', () => {
-      selectOption('customOption');
-      component.removeItem('customOption');
-      expect(component.data).toEqual([]);
-      expect(component.options.length).toBe(4);
-      expect(component.options[0]).toEqual({
-        name: 'customOption',
-        description: '',
-        selected: false
-      });
-    });
-
-    it('will not add an option that is already there', () => {
-      selectOption('option2');
-      expect(component.options.length).toBe(3);
-      expect(component.data).toEqual(['option2']);
-    });
-
-    it('will not add an option twice after each other', () => {
-      selectOption('onlyOnce');
-      expect(component.data).toEqual(['onlyOnce']);
-      selectOption('onlyOnce');
-      expect(component.data).toEqual([]);
-      selectOption('onlyOnce');
-      expect(component.data).toEqual(['onlyOnce']);
-      expect(component.options.length).toBe(4);
-    });
-  });
-
-  describe('if the selection limit is reached', function() {
-    beforeEach(() => {
-      component.selectionLimit = 2;
-      component.triggerSelection(component.options[0]);
-      component.triggerSelection(component.options[1]);
-    });
+  it('should reflect the attributes into CdSelect', () => {
+    const data = ['a', 'b'];
+    const options = [
+      { name: 'option1', description: '', selected: false },
+      { name: 'option2', description: '', selected: false }
+    ];
+    const i18n = TestBed.get(I18n);
+    const messages = new SelectMessages({ empty: 'foo bar' }, i18n);
+    const selectionLimit = 2;
+    const customBadges = true;
+    const customBadgeValidators = [Validators.required];
+
+    component.data = data;
+    component.options = options;
+    component.messages = messages;
+    component.selectionLimit = selectionLimit;
+    component.customBadges = customBadges;
+    component.customBadgeValidators = customBadgeValidators;
 
-    it('will not select more options', () => {
-      component.triggerSelection(component.options[2]);
-      expect(component.data).toEqual(['option1', 'option2']);
-    });
+    fixture.detectChanges();
 
-    it('will unselect options that are selected', () => {
-      component.triggerSelection(component.options[1]);
-      expect(component.data).toEqual(['option1']);
-    });
+    expect(component.cdSelect.data).toEqual(data);
+    expect(component.cdSelect.options).toEqual(options);
+    expect(component.cdSelect.messages).toEqual(messages);
+    expect(component.cdSelect.selectionLimit).toEqual(selectionLimit);
+    expect(component.cdSelect.customBadges).toEqual(customBadges);
+    expect(component.cdSelect.customBadgeValidators).toEqual(customBadgeValidators);
   });
 });
index fcfbf34f1d3ef23375a711646667a4e299d39025..ee364092a203ce336f4ac8ca4b96d82e1cc5cbb8 100644 (file)
-import { Component, Input, OnChanges, OnInit } from '@angular/core';
-import { FormControl, ValidatorFn } from '@angular/forms';
+import { Component, Input, ViewChild } from '@angular/core';
+import { ValidatorFn } from '@angular/forms';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
 import * as _ from 'lodash';
 
-import { CdFormGroup } from '../../forms/cd-form-group';
-import { SelectBadgesMessages } from './select-badges-messages.model';
-import { SelectBadgesOption } from './select-badges-option.model';
+import { SelectMessages } from '../select/select-messages.model';
+import { SelectOption } from '../select/select-option.model';
 
 @Component({
   selector: 'cd-select-badges',
   templateUrl: './select-badges.component.html',
   styleUrls: ['./select-badges.component.scss']
 })
-export class SelectBadgesComponent implements OnInit, OnChanges {
+export class SelectBadgesComponent {
   @Input()
   data: Array<string> = [];
   @Input()
-  options: Array<SelectBadgesOption> = [];
+  options: Array<SelectOption> = [];
   @Input()
-  messages = new SelectBadgesMessages({}, this.i18n);
+  messages = new SelectMessages({}, this.i18n);
   @Input()
   selectionLimit: number;
   @Input()
   customBadges = false;
   @Input()
   customBadgeValidators: ValidatorFn[] = [];
-  form: CdFormGroup;
-  filter: FormControl;
-  Object = Object;
-  filteredOptions: Array<SelectBadgesOption> = [];
 
-  constructor(private i18n: I18n) {}
-
-  ngOnInit() {
-    this.initFilter();
-    if (this.data.length > 0) {
-      this.initMissingOptions();
-    }
-    this.options = _.sortBy(this.options, ['name']);
-    this.updateOptions();
-  }
-
-  private initFilter() {
-    this.filter = new FormControl('', { validators: this.customBadgeValidators });
-    this.form = new CdFormGroup({ filter: this.filter });
-    this.filteredOptions = [...this.options];
-  }
-
-  private initMissingOptions() {
-    const options = this.options.map((option) => option.name);
-    const needToCreate = this.data.filter((option) => options.indexOf(option) === -1);
-    needToCreate.forEach((option) => this.addOption(option));
-    this.forceOptionsToReflectData();
-  }
-
-  private addOption(name: string) {
-    this.options.push(new SelectBadgesOption(false, name, ''));
-    this.options = _.sortBy(this.options, ['name']);
-    this.triggerSelection(this.options.find((option) => option.name === name));
-  }
-
-  triggerSelection(option: SelectBadgesOption) {
-    if (
-      !option ||
-      (this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit)
-    ) {
-      return;
-    }
-    option.selected = !option.selected;
-    this.updateOptions();
-  }
-
-  private updateOptions() {
-    this.data.splice(0, this.data.length);
-    this.options.forEach((option: SelectBadgesOption) => {
-      if (option.selected) {
-        this.data.push(option.name);
-      }
-    });
-    this.updateFilter();
-  }
-
-  updateFilter() {
-    this.filteredOptions = this.options.filter((option) => option.name.includes(this.filter.value));
-  }
+  @ViewChild('cdSelect')
+  cdSelect;
 
-  private forceOptionsToReflectData() {
-    this.options.forEach((option) => {
-      if (this.data.indexOf(option.name) !== -1) {
-        option.selected = true;
-      }
-    });
-  }
-
-  ngOnChanges() {
-    if (!this.options || !this.data || this.data.length === 0) {
-      return;
-    }
-    this.forceOptionsToReflectData();
-  }
-
-  selectOption() {
-    if (this.filteredOptions.length === 0) {
-      this.addCustomOption();
-    } else {
-      this.triggerSelection(this.filteredOptions[0]);
-      this.resetFilter();
-    }
-  }
-
-  addCustomOption() {
-    if (!this.isCreatable()) {
-      return;
-    }
-    this.addOption(this.filter.value);
-    this.resetFilter();
-  }
-
-  isCreatable() {
-    return (
-      this.customBadges &&
-      this.filter.valid &&
-      this.filter.value.length > 0 &&
-      this.filteredOptions.every((option) => option.name !== this.filter.value)
-    );
-  }
-
-  private resetFilter() {
-    this.filter.setValue('');
-    this.updateFilter();
-  }
-
-  removeItem(item: string) {
-    this.triggerSelection(
-      this.options.find((option: SelectBadgesOption) => option.name === item && option.selected)
-    );
-  }
+  constructor(private i18n: I18n) {}
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts
new file mode 100644 (file)
index 0000000..94f8c99
--- /dev/null
@@ -0,0 +1,29 @@
+import { I18n } from '@ngx-translate/i18n-polyfill';
+
+import * as _ from 'lodash';
+
+export class SelectMessages {
+  i18n: I18n;
+
+  empty: string;
+  selectionLimit: any;
+  customValidations = {};
+  filter: string;
+  add: string;
+  noOptions: string;
+
+  constructor(messages: {}, i18n: I18n) {
+    this.i18n = i18n;
+
+    this.empty = this.i18n('No items selected.');
+    this.selectionLimit = {
+      tooltip: this.i18n('Deselect item to select again'),
+      text: this.i18n('Selection limit reached')
+    };
+    this.filter = this.i18n('Filter tags');
+    this.add = this.i18n('Add badge'); // followed by " '{{filter.value}}'"
+    this.noOptions = this.i18n('There are no items available.');
+
+    _.merge(this, messages);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts
new file mode 100644 (file)
index 0000000..5d095b5
--- /dev/null
@@ -0,0 +1,11 @@
+export class SelectOption {
+  selected: boolean;
+  name: string;
+  description: string;
+
+  constructor(selected: boolean, name: string, description: string) {
+    this.selected = selected;
+    this.name = name;
+    this.description = description;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html
new file mode 100644 (file)
index 0000000..ee63842
--- /dev/null
@@ -0,0 +1,79 @@
+<ng-template #popTemplate>
+  <form name="form"
+        #formDir="ngForm"
+        [formGroup]="form"
+        novalidate>
+    <div [ngClass]="{'has-error': form.showError('filter', formDir)}">
+      <input type="text"
+             formControlName="filter"
+             i18n-placeholder
+             [placeholder]="messages.filter"
+             (keyup)="$event.keyCode == 13 ? selectOption() : updateFilter()"
+             class="form-control text-center" />
+      <ng-container *ngFor="let error of Object.keys(messages.customValidations)">
+        <span class="help-block text-center"
+              *ngIf="form.showError('filter', formDir) && filter.hasError(error)">
+          {{ messages.customValidations[error] }}
+        </span>
+      </ng-container>
+    </div>
+  </form>
+  <div *ngFor="let option of filteredOptions"
+       class="select-menu-item"
+       [class.disabled]="data.length === selectionLimit && !option.selected"
+       (click)="triggerSelection(option)">
+    <div class="select-menu-item-icon">
+      <i class="fa fa-check"
+         aria-hidden="true"
+         *ngIf="option.selected"></i>
+      &nbsp;
+    </div>
+    <div class="select-menu-item-content">
+      {{ option.name }}
+      <ng-container *ngIf="option.description">
+        <br>
+        <small class="text-muted">
+          {{ option.description }}&nbsp;
+        </small>
+      </ng-container>
+    </div>
+  </div>
+  <div *ngIf="isCreatable()"
+       class="select-menu-item"
+       (click)="addCustomOption(filter.value)">
+    <div class="select-menu-item-icon">
+      <i class="fa fa-tag"
+         aria-hidden="true"></i>
+      &nbsp;
+    </div>
+    <div class="select-menu-item-content">
+      {{ messages.add }} '{{ filter.value }}'
+    </div>
+  </div>
+  <div class="has-warning"
+       *ngIf="data.length === selectionLimit">
+    <span class="help-block text-center text-warning"
+          [tooltip]="messages.selectionLimit.tooltip"
+          *ngIf="data.length === selectionLimit">
+      {{ messages.selectionLimit.text }}
+    </span>
+  </div>
+</ng-template>
+
+<a class="select-menu-edit"
+   [ngClass]="elemClass"
+   [popover]="popTemplate"
+   placement="bottom"
+   container="body"
+   outsideClick="true"
+   *ngIf="options.length > 0">
+  <ng-content></ng-content>
+</a>
+<span class="text-muted"
+      *ngIf="data.length === 0 && options.length > 0">
+  {{ messages.empty }}
+</span>
+<span class="text-muted"
+      *ngIf="options.length === 0">
+  {{ messages.noOptions }}
+</span>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss
new file mode 100644 (file)
index 0000000..59f633f
--- /dev/null
@@ -0,0 +1,19 @@
+@import '../../../../defaults';
+
+.select-menu-item {
+  display: block;
+  cursor: pointer;
+  border-bottom: 1px solid $color-transparent;
+  font-size: 12px;
+  &:hover {
+    background-color: $color-whitesmoke-gray;
+  }
+}
+.select-menu-item-icon {
+  float: left;
+  padding: 0.5em;
+  width: 3em;
+}
+.select-menu-item-content {
+  padding: 0.5em;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts
new file mode 100644 (file)
index 0000000..bebba6d
--- /dev/null
@@ -0,0 +1,276 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
+
+import { PopoverModule } from 'ngx-bootstrap/popover';
+import { TooltipModule } from 'ngx-bootstrap/tooltip';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { SelectOption } from './select-option.model';
+import { SelectComponent } from './select.component';
+
+describe('SelectComponent', () => {
+  let component: SelectComponent;
+  let fixture: ComponentFixture<SelectComponent>;
+
+  const selectOption = (filter: string) => {
+    component.filter.setValue(filter);
+    component.updateFilter();
+    component.selectOption();
+  };
+
+  configureTestBed({
+    declarations: [SelectComponent],
+    imports: [PopoverModule.forRoot(), TooltipModule, ReactiveFormsModule],
+    providers: i18nProviders
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(SelectComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+    component.options = [
+      { name: 'option1', description: '', selected: false },
+      { name: 'option2', description: '', selected: false },
+      { name: 'option3', description: '', selected: false }
+    ];
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should add item', () => {
+    component.data = [];
+    component.triggerSelection(component.options[1]);
+    expect(component.data).toEqual(['option2']);
+  });
+
+  it('should update selected', () => {
+    component.data = ['option2'];
+    component.ngOnChanges();
+    expect(component.options[0].selected).toBe(false);
+    expect(component.options[1].selected).toBe(true);
+  });
+
+  it('should remove item', () => {
+    component.options.map((option) => {
+      option.selected = true;
+      return option;
+    });
+    component.data = ['option1', 'option2', 'option3'];
+    component.removeItem('option1');
+    expect(component.data).toEqual(['option2', 'option3']);
+  });
+
+  it('should not remove item that is not selected', () => {
+    component.options[0].selected = true;
+    component.data = ['option1'];
+    component.removeItem('option2');
+    expect(component.data).toEqual(['option1']);
+  });
+
+  describe('filter values', () => {
+    beforeEach(() => {
+      component.ngOnInit();
+    });
+
+    it('shows all options with no value set', () => {
+      expect(component.filteredOptions).toEqual(component.options);
+    });
+
+    it('shows one option that it filtered for', () => {
+      component.filter.setValue('2');
+      component.updateFilter();
+      expect(component.filteredOptions).toEqual([component.options[1]]);
+    });
+
+    it('shows all options after selecting something', () => {
+      component.filter.setValue('2');
+      component.updateFilter();
+      component.selectOption();
+      expect(component.filteredOptions).toEqual(component.options);
+    });
+
+    it('is not able to create by default with no value set', () => {
+      component.updateFilter();
+      expect(component.isCreatable()).toBeFalsy();
+    });
+
+    it('is not able to create by default with a value set', () => {
+      component.filter.setValue('2');
+      component.updateFilter();
+      expect(component.isCreatable()).toBeFalsy();
+    });
+  });
+
+  describe('automatically add selected options if not in options array', () => {
+    beforeEach(() => {
+      component.data = ['option1', 'option4'];
+      expect(component.options.length).toBe(3);
+    });
+
+    const expectedResult = () => {
+      expect(component.options.length).toBe(4);
+      expect(component.options[3]).toEqual(new SelectOption(true, 'option4', ''));
+    };
+
+    it('with no extra settings', () => {
+      component.ngOnInit();
+      expectedResult();
+    });
+
+    it('with custom badges', () => {
+      component.customBadges = true;
+      component.ngOnInit();
+      expectedResult();
+    });
+
+    it('with limit higher than selected', () => {
+      component.selectionLimit = 3;
+      component.ngOnInit();
+      expectedResult();
+    });
+
+    it('with limit equal to selected', () => {
+      component.selectionLimit = 2;
+      component.ngOnInit();
+      expectedResult();
+    });
+
+    it('with limit lower than selected', () => {
+      component.selectionLimit = 1;
+      component.ngOnInit();
+      expectedResult();
+    });
+  });
+
+  describe('sorted array and options', () => {
+    beforeEach(() => {
+      component.customBadges = true;
+      component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
+      component.data = ['c', 'b'];
+      component.options = [new SelectOption(true, 'd', ''), new SelectOption(true, 'a', '')];
+      component.ngOnInit();
+    });
+
+    it('has a sorted selection', () => {
+      expect(component.data).toEqual(['a', 'b', 'c', 'd']);
+    });
+
+    it('has a sorted options', () => {
+      const sortedOptions = [
+        new SelectOption(true, 'a', ''),
+        new SelectOption(true, 'b', ''),
+        new SelectOption(true, 'c', ''),
+        new SelectOption(true, 'd', '')
+      ];
+      expect(component.options).toEqual(sortedOptions);
+    });
+
+    it('has a sorted selection after adding an item', () => {
+      selectOption('block');
+      expect(component.data).toEqual(['a', 'b', 'block', 'c', 'd']);
+    });
+
+    it('has a sorted options after adding an item', () => {
+      selectOption('block');
+      const sortedOptions = [
+        new SelectOption(true, 'a', ''),
+        new SelectOption(true, 'b', ''),
+        new SelectOption(true, 'block', ''),
+        new SelectOption(true, 'c', ''),
+        new SelectOption(true, 'd', '')
+      ];
+      expect(component.options).toEqual(sortedOptions);
+    });
+  });
+
+  describe('with custom options', () => {
+    beforeEach(() => {
+      component.customBadges = true;
+      component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
+      component.ngOnInit();
+    });
+
+    it('is not able to create with no value set', () => {
+      component.updateFilter();
+      expect(component.isCreatable()).toBeFalsy();
+    });
+
+    it('is able to create with a valid value set', () => {
+      component.filter.setValue('2');
+      component.updateFilter();
+      expect(component.isCreatable()).toBeTruthy();
+    });
+
+    it('is not able to create with a value set that already exist', () => {
+      component.filter.setValue('option2');
+      component.updateFilter();
+      expect(component.isCreatable()).toBeFalsy();
+    });
+
+    it('adds custom option', () => {
+      selectOption('customOption');
+      expect(component.options[0]).toEqual({
+        name: 'customOption',
+        description: '',
+        selected: true
+      });
+      expect(component.options.length).toBe(4);
+      expect(component.data).toEqual(['customOption']);
+    });
+
+    it('will not add an option that did not pass the validation', () => {
+      selectOption(' this does not pass ');
+      expect(component.options.length).toBe(3);
+      expect(component.data).toEqual([]);
+      expect(component.filter.invalid).toBeTruthy();
+    });
+
+    it('removes custom item selection by name', () => {
+      selectOption('customOption');
+      component.removeItem('customOption');
+      expect(component.data).toEqual([]);
+      expect(component.options.length).toBe(4);
+      expect(component.options[0]).toEqual({
+        name: 'customOption',
+        description: '',
+        selected: false
+      });
+    });
+
+    it('will not add an option that is already there', () => {
+      selectOption('option2');
+      expect(component.options.length).toBe(3);
+      expect(component.data).toEqual(['option2']);
+    });
+
+    it('will not add an option twice after each other', () => {
+      selectOption('onlyOnce');
+      expect(component.data).toEqual(['onlyOnce']);
+      selectOption('onlyOnce');
+      expect(component.data).toEqual([]);
+      selectOption('onlyOnce');
+      expect(component.data).toEqual(['onlyOnce']);
+      expect(component.options.length).toBe(4);
+    });
+  });
+
+  describe('if the selection limit is reached', function() {
+    beforeEach(() => {
+      component.selectionLimit = 2;
+      component.triggerSelection(component.options[0]);
+      component.triggerSelection(component.options[1]);
+    });
+
+    it('will not select more options', () => {
+      component.triggerSelection(component.options[2]);
+      expect(component.data).toEqual(['option1', 'option2']);
+    });
+
+    it('will unselect options that are selected', () => {
+      component.triggerSelection(component.options[1]);
+      expect(component.data).toEqual(['option1']);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts
new file mode 100644 (file)
index 0000000..1728acb
--- /dev/null
@@ -0,0 +1,150 @@
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
+import { FormControl, ValidatorFn } from '@angular/forms';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+
+import { CdFormGroup } from '../../forms/cd-form-group';
+import { SelectMessages } from './select-messages.model';
+import { SelectOption } from './select-option.model';
+
+@Component({
+  selector: 'cd-select',
+  templateUrl: './select.component.html',
+  styleUrls: ['./select.component.scss']
+})
+export class SelectComponent implements OnInit, OnChanges {
+  @Input()
+  elemClass: string;
+  @Input()
+  data: Array<string> = [];
+  @Input()
+  options: Array<SelectOption> = [];
+  @Input()
+  messages = new SelectMessages({}, this.i18n);
+  @Input()
+  selectionLimit: number;
+  @Input()
+  customBadges = false;
+  @Input()
+  customBadgeValidators: ValidatorFn[] = [];
+
+  @Output()
+  selection = new EventEmitter();
+
+  form: CdFormGroup;
+  filter: FormControl;
+  Object = Object;
+  filteredOptions: Array<SelectOption> = [];
+
+  constructor(private i18n: I18n) {}
+
+  ngOnInit() {
+    this.initFilter();
+    if (this.data.length > 0) {
+      this.initMissingOptions();
+    }
+    this.options = _.sortBy(this.options, ['name']);
+    this.updateOptions();
+  }
+
+  private initFilter() {
+    this.filter = new FormControl('', { validators: this.customBadgeValidators });
+    this.form = new CdFormGroup({ filter: this.filter });
+    this.filteredOptions = [...(this.options || [])];
+  }
+
+  private initMissingOptions() {
+    const options = this.options.map((option) => option.name);
+    const needToCreate = this.data.filter((option) => options.indexOf(option) === -1);
+    needToCreate.forEach((option) => this.addOption(option));
+    this.forceOptionsToReflectData();
+  }
+
+  private addOption(name: string) {
+    this.options.push(new SelectOption(false, name, ''));
+    this.options = _.sortBy(this.options, ['name']);
+    this.triggerSelection(this.options.find((option) => option.name === name));
+  }
+
+  triggerSelection(option: SelectOption) {
+    if (
+      !option ||
+      (this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit)
+    ) {
+      return;
+    }
+    option.selected = !option.selected;
+    this.updateOptions();
+    this.selection.emit({ option: option });
+  }
+
+  private updateOptions() {
+    this.data.splice(0, this.data.length);
+    this.options.forEach((option: SelectOption) => {
+      if (option.selected) {
+        this.data.push(option.name);
+      }
+    });
+    this.updateFilter();
+  }
+
+  updateFilter() {
+    this.filteredOptions = this.options.filter((option) => option.name.includes(this.filter.value));
+  }
+
+  private forceOptionsToReflectData() {
+    this.options.forEach((option) => {
+      if (this.data.indexOf(option.name) !== -1) {
+        option.selected = true;
+      }
+    });
+  }
+
+  ngOnChanges() {
+    if (this.filter) {
+      this.updateFilter();
+    }
+    if (!this.options || !this.data || this.data.length === 0) {
+      return;
+    }
+    this.forceOptionsToReflectData();
+  }
+
+  selectOption() {
+    if (this.filteredOptions.length === 0) {
+      this.addCustomOption();
+    } else {
+      this.triggerSelection(this.filteredOptions[0]);
+      this.resetFilter();
+    }
+  }
+
+  addCustomOption() {
+    if (!this.isCreatable()) {
+      return;
+    }
+    this.addOption(this.filter.value);
+    this.resetFilter();
+  }
+
+  isCreatable() {
+    return (
+      this.customBadges &&
+      this.filter.valid &&
+      this.filter.value.length > 0 &&
+      this.filteredOptions.every((option) => option.name !== this.filter.value)
+    );
+  }
+
+  private resetFilter() {
+    this.filter.setValue('');
+    this.updateFilter();
+  }
+
+  removeItem(item: string) {
+    this.triggerSelection(
+      this.options.find((option: SelectOption) => option.name === item && option.selected)
+    );
+  }
+}