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 {
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: {
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');
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;
};
-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;
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';
userFormMode = UserFormMode;
mode: UserFormMode;
allRoles: Array<UserFormRoleModel>;
- messages: SelectBadgesMessages;
+ messages = new SelectMessages({ empty: 'There are no roles.' }, this.i18n);
constructor(
private authService: AuthService,
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() {
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';
ConfirmationModalComponent,
WarningPanelComponent,
LanguageSelectorComponent,
- GrafanaComponent
+ GrafanaComponent,
+ SelectComponent
],
providers: [],
exports: [
ModalComponent,
WarningPanelComponent,
LanguageSelectorComponent,
- GrafanaComponent
+ GrafanaComponent,
+ SelectComponent
],
entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent]
})
+++ /dev/null
-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);
- }
-}
+++ /dev/null
-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;
- }
-}
-<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>
-
- </div>
- <div class="select-menu-item-content">
- {{ option.name }}
- <ng-container *ngIf="option.description">
- <br>
- <small class="text-muted">
- {{ option.description }}
- </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>
-
- </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>
@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;
}
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
});
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);
});
});
-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) {}
}
--- /dev/null
+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);
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+<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>
+
+ </div>
+ <div class="select-menu-item-content">
+ {{ option.name }}
+ <ng-container *ngIf="option.description">
+ <br>
+ <small class="text-muted">
+ {{ option.description }}
+ </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>
+
+ </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>
--- /dev/null
+@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;
+}
--- /dev/null
+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']);
+ });
+ });
+});
--- /dev/null
+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)
+ );
+ }
+}