From 7acdff9a0fd8548d970053a7a4845659770afc52 Mon Sep 17 00:00:00 2001 From: Tiago Melo Date: Tue, 15 Jan 2019 15:28:00 +0000 Subject: [PATCH] mgr/dashboard: Add generic Select component Signed-off-by: Tiago Melo --- .../app/ceph/pool/pool-form/pool-form-data.ts | 12 +- .../pool-form/pool-form.component.spec.ts | 12 +- .../auth/user-form/user-form-role.model.ts | 4 +- .../auth/user-form/user-form.component.ts | 6 +- .../shared/components/components.module.ts | 7 +- .../select-badges.component.html | 84 +----- .../select-badges.component.scss | 19 +- .../select-badges.component.spec.ts | 276 ++---------------- .../select-badges/select-badges.component.ts | 127 +------- .../select-messages.model.ts} | 7 +- .../select-option.model.ts} | 2 +- .../components/select/select.component.html | 79 +++++ .../components/select/select.component.scss | 19 ++ .../select/select.component.spec.ts | 276 ++++++++++++++++++ .../components/select/select.component.ts | 150 ++++++++++ 15 files changed, 603 insertions(+), 477 deletions(-) rename src/pybind/mgr/dashboard/frontend/src/app/shared/components/{select-badges/select-badges-messages.model.ts => select/select-messages.model.ts} (77%) rename src/pybind/mgr/dashboard/frontend/src/app/shared/components/{select-badges/select-badges-option.model.ts => select/select-option.model.ts} (86%) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts index 9e1a6076457..f6036b6f83b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts @@ -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: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts index 91160ec98c7..b672d371c3f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts @@ -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; }; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts index 28caa3f9dae..3bc8f23892c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts index 8d2204e25f6..826b2de46bf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts @@ -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; - 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() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 95107103096..02c54aa160c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -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.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html index 45a3ebf2b85..8d65bda260e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html @@ -1,81 +1,19 @@ - -
-
- - - - {{ messages.customValidations[error] }} - - -
-
-
-
- -   -
-
- {{ option.name }} - -
- - {{ option.description }}  - -
-
-
-
-
- -   -
-
- {{ messages.add }} '{{ filter.value }}' -
-
-
- - {{ messages.selectionLimit.text }} - -
-
- - + - - - {{ messages.empty }} - + + {{ dataItem }} + (click)="cdSelect.removeItem(dataItem)"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss index 74814b636db..d5f2a30d099 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss @@ -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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts index cc539353ee8..67221be4fe6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts @@ -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; - 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); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts index fcfbf34f1d3..ee364092a20 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts @@ -1,140 +1,33 @@ -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 = []; @Input() - options: Array = []; + options: Array = []; @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 = []; - 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-badges/select-badges-messages.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts similarity index 77% rename from src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-messages.model.ts rename to src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts index c5306411826..94f8c997df1 100644 --- 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/select-messages.model.ts @@ -2,24 +2,27 @@ import { I18n } from '@ngx-translate/i18n-polyfill'; import * as _ from 'lodash'; -export class SelectBadgesMessages { +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('There are no items.'); + 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-badges/select-badges-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts similarity index 86% rename from src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges-option.model.ts rename to src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts index 49f29330fa3..5d095b58980 100644 --- 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/select-option.model.ts @@ -1,4 +1,4 @@ -export class SelectBadgesOption { +export class SelectOption { selected: boolean; name: string; description: string; 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 index 00000000000..ee63842c1fd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html @@ -0,0 +1,79 @@ + +
+
+ + + + {{ messages.customValidations[error] }} + + +
+
+
+
+ +   +
+
+ {{ option.name }} + +
+ + {{ option.description }}  + +
+
+
+
+
+ +   +
+
+ {{ messages.add }} '{{ filter.value }}' +
+
+
+ + {{ messages.selectionLimit.text }} + +
+
+ + + + + + {{ messages.empty }} + + + {{ messages.noOptions }} + 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 index 00000000000..59f633f73b7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss @@ -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 index 00000000000..bebba6d9654 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts @@ -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; + + 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 index 00000000000..1728acb5ab6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts @@ -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 = []; + @Input() + options: Array = []; + @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 = []; + + 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) + ); + } +} -- 2.39.5