]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add badge filtering
authorStephan Müller <smueller@suse.com>
Thu, 9 Aug 2018 12:02:50 +0000 (14:02 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 9 Oct 2018 12:54:51 +0000 (14:54 +0200)
It's now possible to filter badges and create badges via a create badges
label.

Fixes: https://tracker.ceph.com/issues/36357
Signed-off-by: Stephan Müller <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-messages.model.ts [new file with mode: 0644]
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.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts

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
new file mode 100644 (file)
index 0000000..cbe2988
--- /dev/null
@@ -0,0 +1,16 @@
+import * as _ from 'lodash';
+
+export class SelectBadgesMessages {
+  empty = 'There are no items.';
+  selectionLimit = {
+    tooltip: 'Deselect item to select again',
+    text: 'Selection limit reached'
+  };
+  customValidations = {};
+  filter = 'Filter tags';
+  add = 'Add badge'; // followed by " '{{filter.value}}'"
+
+  constructor(messages: {}) {
+    _.merge(this, messages);
+  }
+}
index 22ceb6901111da11ac58a8bec4d503d32b12a3c5..05d33b70e8f870c05501906eea0a20b1f6c46e1a 100644 (file)
@@ -1,34 +1,28 @@
 <ng-template #popTemplate>
-  <ng-container *ngIf="customBadges">
-    <form name="form"
-          #formDir="ngForm"
-          [formGroup]="form"
-          novalidate>
-      <div [ngClass]="{'has-error': form.showError('customBadge', formDir)}">
-        <input type="text"
-               formControlName="customBadge"
-               i18n-placeholder
-               [placeholder]="customBadgeMessage"
-               (keyup)="$event.keyCode == 13 ? addCustomOption(customBadge) : null"
-               class="form-control text-center"/>
-        <ng-container *ngFor="let error of Object.keys(errorMessages.custom.validation)">
-          <span
-            i18n
-            class="help-block text-center"
-            *ngIf="form.showError('customBadge', formDir) && customBadge.hasError(error)">
-            {{ errorMessages.custom.validation[error] }}
-          </span>
-        </ng-container>
-        <span i18n
-              class="help-block text-center"
-              *ngIf="form.showError('customBadge', formDir) && customBadge.hasError('duplicate')">
-          {{ errorMessages.custom.duplicate }}
+  <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
+          i18n
+          class="help-block text-center"
+          *ngIf="form.showError('filter', formDir) && filter.hasError(error)">
+          {{ messages.customValidations[error] }}
         </span>
-      </div>
-    </form>
-  </ng-container>
-  <div *ngFor="let option of options"
+      </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"
       </ng-container>
     </div>
   </div>
-  <span i18n
-        class="help-block text-center"
-        *ngIf="data.length === selectionLimit">
-          {{ errorMessages.selectionLimit }}
-  </span>
+  <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 i18n
+          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"
@@ -62,7 +71,7 @@
 <span class="text-muted"
       *ngIf="data.length === 0"
       i18n>
-  {{ errorMessages.empty }}
+  {{ messages.empty }}
 </span>
 <span *ngFor="let dataItem of data">
   <span class="badge badge-pill badge-primary margin-right-sm">
index 0c311d4a3895249f985f5feff93d8b531f4fceff..abb587b7c46ff76229c5ce0711ae36b5199ec696 100644 (file)
@@ -1,8 +1,8 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule, Validators } from '@angular/forms';
 
-import { PopoverModule } from 'ngx-bootstrap';
+import { PopoverModule, TooltipModule } from 'ngx-bootstrap';
 
-import { FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
 import { configureTestBed } from '../../../../testing/unit-test-helper';
 import { SelectBadgesOption } from './select-badges-option.model';
 import { SelectBadgesComponent } from './select-badges.component';
@@ -11,9 +11,15 @@ describe('SelectBadgesComponent', () => {
   let component: SelectBadgesComponent;
   let fixture: ComponentFixture<SelectBadgesComponent>;
 
+  const selectOption = (filter: string) => {
+    component.filter.setValue(filter);
+    component.updateFilter();
+    component.selectOption();
+  };
+
   configureTestBed({
     declarations: [SelectBadgesComponent],
-    imports: [PopoverModule.forRoot(), FormsModule, ReactiveFormsModule]
+    imports: [PopoverModule.forRoot(), TooltipModule, ReactiveFormsModule]
   });
 
   beforeEach(() => {
@@ -61,6 +67,40 @@ describe('SelectBadgesComponent', () => {
     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'];
@@ -102,36 +142,98 @@ describe('SelectBadgesComponent', () => {
     });
   });
 
+  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();
-      component.customBadge.setValue('customOption');
-      component.addCustomOption();
+    });
+
+    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', () => {
-      expect(component.options[3]).toEqual({
+      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', () => {
-      component.customBadge.setValue(' this does not pass ');
-      component.addCustomOption();
-      expect(component.options.length).toBe(4);
-      expect(component.data).toEqual(['customOption']);
-      expect(component.customBadge.invalid).toBeTruthy();
+      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[3]).toEqual({
+      expect(component.options.length).toBe(4);
+      expect(component.options[0]).toEqual({
         name: 'customOption',
         description: '',
         selected: false
@@ -139,18 +241,19 @@ describe('SelectBadgesComponent', () => {
     });
 
     it('will not add an option that is already there', () => {
-      component.customBadge.setValue('option2');
-      component.addCustomOption();
-      expect(component.options.length).toBe(4);
-      expect(component.data).toEqual(['customOption']);
+      selectOption('option2');
+      expect(component.options.length).toBe(3);
+      expect(component.data).toEqual(['option2']);
     });
 
     it('will not add an option twice after each other', () => {
-      component.customBadge.setValue('onlyOnce');
-      component.addCustomOption();
-      component.addCustomOption();
-      expect(component.data).toEqual(['customOption', 'onlyOnce']);
-      expect(component.options.length).toBe(5);
+      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);
     });
   });
 
index f42a89037280718695345f68633ac9edb385cf56..0d3bec8bb6ac23cdf8c270c9eec18de79dc68339 100644 (file)
@@ -1,8 +1,10 @@
-import { Component, OnChanges, OnInit } from '@angular/core';
-import { Input } from '@angular/core';
+import { Component, Input, OnChanges, OnInit } from '@angular/core';
 import { FormControl, ValidatorFn } from '@angular/forms';
+
+import * as _ from 'lodash';
+
 import { CdFormGroup } from '../../forms/cd-form-group';
-import { CdValidators } from '../../forms/cd-validators';
+import { SelectBadgesMessages } from './select-badges-messages.model';
 import { SelectBadgesOption } from './select-badges-option.model';
 
 @Component({
@@ -11,45 +13,38 @@ import { SelectBadgesOption } from './select-badges-option.model';
   styleUrls: ['./select-badges.component.scss']
 })
 export class SelectBadgesComponent implements OnInit, OnChanges {
-  @Input() data: Array<string> = [];
-  @Input() options: Array<SelectBadgesOption> = [];
   @Input()
-  errorMessages = {
-    empty: 'There are no items.',
-    selectionLimit: 'Selection limit reached',
-    custom: {
-      validation: {},
-      duplicate: 'Already exits'
-    }
-  };
-  @Input() selectionLimit: number;
-  @Input() customBadges = false;
-  @Input() customBadgeValidators: ValidatorFn[] = [];
-  @Input() customBadgeMessage = 'Use custom tag';
+  data: Array<string> = [];
+  @Input()
+  options: Array<SelectBadgesOption> = [];
+  @Input()
+  messages = new SelectBadgesMessages({});
+  @Input()
+  selectionLimit: number;
+  @Input()
+  customBadges = false;
+  @Input()
+  customBadgeValidators: ValidatorFn[] = [];
   form: CdFormGroup;
-  customBadge: FormControl;
+  filter: FormControl;
   Object = Object;
+  filteredOptions: Array<SelectBadgesOption> = [];
 
   constructor() {}
 
   ngOnInit() {
-    if (this.customBadges) {
-      this.initCustomBadges();
-    }
+    this.initFilter();
     if (this.data.length > 0) {
       this.initMissingOptions();
     }
+    this.options = _.sortBy(this.options, ['name']);
+    this.updateOptions();
   }
 
-  private initCustomBadges() {
-    this.customBadgeValidators.push(
-      CdValidators.custom(
-        'duplicate',
-        (badge) => this.options && this.options.some((option) => option.name === badge)
-      )
-    );
-    this.customBadge = new FormControl('', { validators: this.customBadgeValidators });
-    this.form = new CdFormGroup({ customBadge: this.customBadge });
+  private initFilter() {
+    this.filter = new FormControl('', { validators: this.customBadgeValidators });
+    this.form = new CdFormGroup({ filter: this.filter });
+    this.filteredOptions = [...this.options];
   }
 
   private initMissingOptions() {
@@ -61,10 +56,11 @@ export class SelectBadgesComponent implements OnInit, OnChanges {
 
   private addOption(name: string) {
     this.options.push(new SelectBadgesOption(false, name, ''));
-    this.triggerSelection(this.options[this.options.length - 1]);
+    this.options = _.sortBy(this.options, ['name']);
+    this.triggerSelection(this.options.find((option) => option.name === name));
   }
 
-  private triggerSelection(option: SelectBadgesOption) {
+  triggerSelection(option: SelectBadgesOption) {
     if (
       !option ||
       (this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit)
@@ -82,6 +78,11 @@ export class SelectBadgesComponent implements OnInit, OnChanges {
         this.data.push(option.name);
       }
     });
+    this.updateFilter();
+  }
+
+  updateFilter() {
+    this.filteredOptions = this.options.filter((option) => option.name.includes(this.filter.value));
   }
 
   private forceOptionsToReflectData() {
@@ -99,12 +100,35 @@ export class SelectBadgesComponent implements OnInit, OnChanges {
     this.forceOptionsToReflectData();
   }
 
+  selectOption() {
+    if (this.filteredOptions.length === 0) {
+      this.addCustomOption();
+    } else {
+      this.triggerSelection(this.filteredOptions[0]);
+      this.resetFilter();
+    }
+  }
+
   addCustomOption() {
-    if (this.customBadge.invalid || this.customBadge.value.length === 0) {
+    if (!this.isCreatable()) {
       return;
     }
-    this.addOption(this.customBadge.value);
-    this.customBadge.setValue('');
+    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) {