mgr/dashboard: Enable custom badges

Enables custom badges within badges component.

It's possible to use custom validations and custom error messages.

Fixes: https://tracker.ceph.com/issues/36357
Signed-off-by: Stephan Müller <smueller@suse.com>
This commit is contained in:
Stephan Müller 2018-08-07 14:23:26 +02:00
parent bc9f34eacc
commit bc7ead0bc6
5 changed files with 270 additions and 48 deletions

View File

@ -1,5 +1,11 @@
interface SelectBadgesOption {
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;
}
}

View File

@ -1,7 +1,35 @@
<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 }}
</span>
</div>
</form>
</ng-container>
<div *ngFor="let option of options"
class="select-menu-item"
(click)="selectOption(option)">
(click)="triggerSelection(option)">
<div class="select-menu-item-icon">
<i class="fa fa-check" aria-hidden="true"
*ngIf="option.selected"></i>
@ -9,12 +37,19 @@
</div>
<div class="select-menu-item-content">
{{ option.name }}
<br>
<small class="text-muted">
{{ option.description }}&nbsp;
</small>
<ng-container *ngIf="option.description">
<br>
<small class="text-muted">
{{ option.description }}&nbsp;
</small>
</ng-container>
</div>
</div>
<span i18n
class="help-block text-center"
*ngIf="data.length === selectionLimit">
{{ errorMessages.selectionLimit }}
</span>
</ng-template>
<a class="margin-right-sm select-menu-edit"
@ -27,7 +62,7 @@
<span class="text-muted"
*ngIf="data.length === 0"
i18n>
{{ emptyMessage }}
{{ errorMessages.empty }}
</span>
<span *ngFor="let dataItem of data">
<span class="badge badge-pill badge-primary margin-right-sm">

View File

@ -14,11 +14,11 @@
}
.select-menu-item-icon {
float: left;
padding: 8px 8px 8px 8px;
width: 30px;
padding: 0.5em;
width: 3em;
}
.select-menu-item-content {
padding: 8px 8px 8px 8px;
padding: 0.5em;
}
.badge-remove {
color: $color-solid-white;

View File

@ -2,7 +2,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PopoverModule } 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';
describe('SelectBadgesComponent', () => {
@ -11,13 +13,18 @@ describe('SelectBadgesComponent', () => {
configureTestBed({
declarations: [SelectBadgesComponent],
imports: [PopoverModule.forRoot()]
imports: [PopoverModule.forRoot(), FormsModule, ReactiveFormsModule]
});
beforeEach(() => {
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', () => {
@ -25,20 +32,12 @@ describe('SelectBadgesComponent', () => {
});
it('should add item', () => {
component.options = [
{ name: 'option1', description: '', selected: false },
{ name: 'option2', description: '', selected: false }
];
component.data = [];
component.selectOption(component.options[1]);
component.triggerSelection(component.options[1]);
expect(component.data).toEqual(['option2']);
});
it('should update selected', () => {
component.options = [
{ name: 'option1', description: '', selected: false },
{ name: 'option2', description: '', selected: false }
];
component.data = ['option2'];
component.ngOnChanges();
expect(component.options[0].selected).toBe(false);
@ -46,12 +45,130 @@ describe('SelectBadgesComponent', () => {
});
it('should remove item', () => {
component.options = [
{ name: 'option1', description: '', selected: true },
{ name: 'option2', description: '', selected: true }
];
component.data = ['option1', 'option2'];
component.options.map((option) => {
option.selected = true;
return option;
});
component.data = ['option1', 'option2', 'option3'];
component.removeItem('option1');
expect(component.data).toEqual(['option2']);
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('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('with custom options', () => {
beforeEach(() => {
component.customBadges = true;
component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')];
component.ngOnInit();
component.customBadge.setValue('customOption');
component.addCustomOption();
});
it('adds custom option', () => {
expect(component.options[3]).toEqual({
name: 'customOption',
description: '',
selected: true
});
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();
});
it('removes custom item selection by name', () => {
component.removeItem('customOption');
expect(component.data).toEqual([]);
expect(component.options[3]).toEqual({
name: 'customOption',
description: '',
selected: false
});
});
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']);
});
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);
});
});
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']);
});
});
});

View File

@ -1,30 +1,78 @@
import { Component, OnChanges } from '@angular/core';
import { Component, OnChanges, OnInit } from '@angular/core';
import { Input } from '@angular/core';
import { FormControl, ValidatorFn } from '@angular/forms';
import { CdFormGroup } from '../../forms/cd-form-group';
import { CdValidators } from '../../forms/cd-validators';
import { SelectBadgesOption } from './select-badges-option.model';
@Component({
selector: 'cd-select-badges',
templateUrl: './select-badges.component.html',
styleUrls: ['./select-badges.component.scss']
})
export class SelectBadgesComponent implements OnChanges {
export class SelectBadgesComponent implements OnInit, OnChanges {
@Input() data: Array<string> = [];
@Input() options: Array<SelectBadgesOption> = [];
@Input()
data: Array<string> = [];
@Input()
options: Array<SelectBadgesOption> = [];
@Input()
emptyMessage = 'There are no items.';
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';
form: CdFormGroup;
customBadge: FormControl;
Object = Object;
constructor() {}
ngOnChanges() {
if (!this.options || !this.data || this.data.length === 0) {
ngOnInit() {
if (this.customBadges) {
this.initCustomBadges();
}
if (this.data.length > 0) {
this.initMissingOptions();
}
}
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 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.triggerSelection(this.options[this.options.length - 1]);
}
private triggerSelection(option: SelectBadgesOption) {
if (
!option ||
(this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit)
) {
return;
}
this.options.forEach((option) => {
if (this.data.indexOf(option.name) !== -1) {
option.selected = true;
}
});
option.selected = !option.selected;
this.updateOptions();
}
private updateOptions() {
@ -36,16 +84,32 @@ export class SelectBadgesComponent implements OnChanges {
});
}
selectOption(option: SelectBadgesOption) {
option.selected = !option.selected;
this.updateOptions();
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();
}
addCustomOption() {
if (this.customBadge.invalid || this.customBadge.value.length === 0) {
return;
}
this.addOption(this.customBadge.value);
this.customBadge.setValue('');
}
removeItem(item: string) {
const optionToRemove = this.options.find((option: SelectBadgesOption) => {
return option.name === item;
});
optionToRemove.selected = false;
this.updateOptions();
this.triggerSelection(
this.options.find((option: SelectBadgesOption) => option.name === item && option.selected)
);
}
}