Skip to content

Commit 395c49a

Browse files
committed
mgr/dashboard: add custom items to combo box
previously we were able to add custom items to our select-badges like custom labels for hosts. but it got dropped unintentionally due to the carbon. fixing it here Fixes: https://tracker.ceph.com/issues/68871 Signed-off-by: Nizamudeen A <[email protected]>
1 parent a087640 commit 395c49a

File tree

10 files changed

+201
-7
lines changed

10 files changed

+201
-7
lines changed

src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
[invalid]="form.controls.label.invalid && (form.controls.label.dirty)"
8888
[invalidText]="labelError"
8989
cdRequiredField="Label"
90+
cdDynamicInputCombobox
91+
(updatedItems)="data.labels = $event"
9092
i18n>
9193
<cds-dropdown-list></cds-dropdown-list>
9294
</cds-combo-box>

src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,10 @@
8080
i18n-placeholder
8181
[appendInline]="true"
8282
[items]="labelsOption"
83-
itemValueKey="value"
83+
itemValueKey="content"
8484
id="labels"
85+
cdDynamicInputCombobox
86+
(updatedItems)="labelsOption = $event"
8587
i18n>
8688
<cds-dropdown-list></cds-dropdown-list>
8789
</cds-combo-box>

src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import expand from 'brace-expansion';
55

66
import { HostService } from '~/app/shared/api/host.service';
77
import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
8-
import { SelectOption } from '~/app/shared/components/select/select-option.model';
98
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
109
import { CdForm } from '~/app/shared/forms/cd-form';
1110
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
1211
import { CdValidators } from '~/app/shared/forms/cd-validators';
1312
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
13+
import { ComboBoxItem } from '~/app/shared/models/combo-box.model';
1414
import { FinishedTask } from '~/app/shared/models/finished-task';
1515
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
1616

@@ -32,7 +32,7 @@ export class HostFormComponent extends CdForm implements OnInit {
3232
allLabels: string[];
3333
pageURL: string;
3434
hostPattern = false;
35-
labelsOption: Array<SelectOption> = [];
35+
labelsOption: ComboBoxItem[] = [];
3636

3737
messages = new SelectMessages({
3838
empty: $localize`There are no labels.`,
@@ -71,7 +71,7 @@ export class HostFormComponent extends CdForm implements OnInit {
7171
this.hostService.getLabels().subscribe((resp: string[]) => {
7272
const uniqueLabels = new Set(resp.concat(this.hostService.predefinedLabels));
7373
this.labelsOption = Array.from(uniqueLabels).map((label) => {
74-
return { enabled: true, name: label, content: label, selected: false, description: null };
74+
return { name: label, content: label };
7575
});
7676
});
7777
}

src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,12 @@
8383
[formControlName]="field.name"
8484
itemValueKey="content"
8585
[id]="field.name"
86-
[items]="field?.typeConfig?.options"
8786
[invalid]="getError(field)"
8887
[invalidText]="getError(field)"
8988
[appendInline]="false"
89+
cdDynamicInputCombobox
90+
(updatedItems)="field.typeConfig.options = $event"
91+
[items]="field?.typeConfig?.options"
9092
[cdRequiredField]="field?.required === true ? field.label : ''"
9193
i18n>
9294
<cds-dropdown-list></cds-dropdown-list>

src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { RequiredFieldDirective } from './required-field.directive';
1919
import { ReactiveFormsModule } from '@angular/forms';
2020
import { OptionalFieldDirective } from './optional-field.directive';
2121
import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.directive';
22+
import { DynamicInputComboboxDirective } from './dynamic-input-combobox.directive';
2223

2324
@NgModule({
2425
imports: [ReactiveFormsModule],
@@ -40,7 +41,8 @@ import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.dir
4041
AuthStorageDirective,
4142
RequiredFieldDirective,
4243
OptionalFieldDirective,
43-
DimlessBinaryPerMinuteDirective
44+
DimlessBinaryPerMinuteDirective,
45+
DynamicInputComboboxDirective
4446
],
4547
exports: [
4648
AutofocusDirective,
@@ -60,7 +62,8 @@ import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.dir
6062
AuthStorageDirective,
6163
RequiredFieldDirective,
6264
OptionalFieldDirective,
63-
DimlessBinaryPerMinuteDirective
65+
DimlessBinaryPerMinuteDirective,
66+
DynamicInputComboboxDirective
6467
]
6568
})
6669
export class DirectivesModule {}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
2+
import { DEBOUNCE_TIMER, DynamicInputComboboxDirective } from './dynamic-input-combobox.directive';
3+
import { Subject } from 'rxjs';
4+
import { Component, EventEmitter } from '@angular/core';
5+
import { ComboBoxItem } from '../models/combo-box.model';
6+
7+
@Component({
8+
template: `<div cdDynamicInputCombobox
9+
[items]="[]"></div>`,
10+
})
11+
class MockComponent {
12+
items: ComboBoxItem[] = [{ content: 'Item1', name: 'Item1' }];
13+
searchSubject = new Subject<string>();
14+
selectedItems: ComboBoxItem[] = [];
15+
updatedItems = new EventEmitter<ComboBoxItem[]>();
16+
}
17+
18+
describe('DynamicInputComboboxDirective', () => {
19+
20+
let component: MockComponent;
21+
let fixture: ComponentFixture<MockComponent>;
22+
let directive: DynamicInputComboboxDirective;
23+
24+
beforeEach(() => {
25+
TestBed.configureTestingModule({
26+
declarations: [DynamicInputComboboxDirective, MockComponent],
27+
}).compileComponents();
28+
29+
fixture = TestBed.createComponent(MockComponent);
30+
component = fixture.componentInstance;
31+
32+
directive = fixture.debugElement.children[0].injector.get(DynamicInputComboboxDirective);
33+
fixture.detectChanges();
34+
});
35+
36+
afterEach(() => {
37+
directive.ngOnDestroy();
38+
});
39+
40+
it('should create an instance', () => {
41+
expect(directive).toBeTruthy();
42+
});
43+
44+
it('should update items when input is given', fakeAsync(() => {
45+
const newItem = 'NewItem';
46+
directive.onInput(newItem);
47+
tick(DEBOUNCE_TIMER);
48+
49+
expect(directive.items[0].content).toBe(newItem);
50+
}));
51+
52+
it('should not unselect selected items', fakeAsync(() => {
53+
const selectedItems: ComboBoxItem[] = [{
54+
content: 'selectedItem',
55+
name: 'selectedItem',
56+
selected: true
57+
}];
58+
59+
directive.items = selectedItems;
60+
61+
directive.onSelected(selectedItems);
62+
tick(DEBOUNCE_TIMER);
63+
64+
directive.onInput(selectedItems[0].content);
65+
tick(DEBOUNCE_TIMER);
66+
67+
expect(directive.items[0].content).toBe(selectedItems[0].content);
68+
expect(directive.items[0].selected).toBeTruthy();
69+
}))
70+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Directive, Input, OnDestroy, OnInit, Output, EventEmitter, HostListener } from '@angular/core';
2+
import { ComboBoxItem } from '../models/combo-box.model';
3+
import { ComboBoxService } from '../services/combo-box.service';
4+
import { Subject, Subscription } from 'rxjs';
5+
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
6+
7+
export const DEBOUNCE_TIMER = 300;
8+
9+
/**
10+
* Directive to introduce adding custom items to the carbon combo-box component
11+
* It takes the inputs of the combo-box and then append it with the searched item.
12+
* Then it emits the updatedItems back to the <cds-combobox> element
13+
*/
14+
@Directive({
15+
selector: '[cdDynamicInputCombobox]'
16+
})
17+
export class DynamicInputComboboxDirective implements OnInit, OnDestroy {
18+
/**
19+
* This input is the same as what we have in the <cds-combobox> element.
20+
*/
21+
@Input() items: ComboBoxItem[];
22+
23+
/**
24+
* This will hold the items of the combo-box appended with the searched items.
25+
*/
26+
@Output() updatedItems: EventEmitter<ComboBoxItem[]> = new EventEmitter();
27+
28+
private searchSubscription: Subscription;
29+
private searchSubject: Subject<string> = new Subject();
30+
private selectedItems: ComboBoxItem[] = [];
31+
32+
constructor(
33+
private combBoxService: ComboBoxService
34+
) { }
35+
36+
ngOnInit() {
37+
this.searchSubscription = this.searchSubject
38+
.pipe(
39+
debounceTime(DEBOUNCE_TIMER),
40+
distinctUntilChanged()
41+
)
42+
.subscribe((searchString) => {
43+
// Already selected items should be selected in the dropdown
44+
// even if the items are updated again
45+
this.items = this.items.map((item: ComboBoxItem) => {
46+
const selected = this.selectedItems.some(
47+
(selectedItem: ComboBoxItem) => selectedItem.content === item.content
48+
);
49+
return { ...item, selected };
50+
});
51+
52+
const exists = this.items.some(
53+
(item: ComboBoxItem) => item.content === searchString
54+
);
55+
56+
if (!exists) {
57+
this.items = this.items.concat({ content: searchString, name: searchString });
58+
}
59+
this.updatedItems.emit(this.items );
60+
this.combBoxService.emit({ searchString });
61+
});
62+
}
63+
64+
@HostListener('search', ['$event'])
65+
onInput(event: string) {
66+
if (event.length > 1) this.searchSubject.next(event);
67+
}
68+
69+
@HostListener('selected', ['$event'])
70+
onSelected(event: ComboBoxItem[]) {
71+
this.selectedItems = event;
72+
}
73+
74+
ngOnDestroy() {
75+
this.searchSubscription.unsubscribe();
76+
this.searchSubject.complete();
77+
}
78+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type ComboBoxItem = {
2+
content: string;
3+
name: string;
4+
selected?: boolean;
5+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { ComboBoxService } from './combo-box.service';
4+
5+
describe('ComboBoxService', () => {
6+
let service: ComboBoxService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(ComboBoxService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Injectable } from '@angular/core';
2+
import { Subject } from 'rxjs';
3+
4+
@Injectable({
5+
providedIn: 'root'
6+
})
7+
export class ComboBoxService {
8+
private searchSubject = new Subject<{ searchString: string }>();
9+
10+
constructor() {
11+
}
12+
13+
emit(value: { searchString: string }) {
14+
this.searchSubject.next(value);
15+
}
16+
}

0 commit comments

Comments
 (0)