From 395c49a43d283fc0a2435a07390cd79dd166669a Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Fri, 27 Sep 2024 11:00:17 +0530 Subject: [PATCH] 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 --- .../cephfs-form/cephfs-form.component.html | 2 + .../hosts/host-form/host-form.component.html | 4 +- .../hosts/host-form/host-form.component.ts | 6 +- .../form-modal/form-modal.component.html | 4 +- .../shared/directives/directives.module.ts | 7 +- .../dynamic-input-combobox.directive.spec.ts | 70 +++++++++++++++++ .../dynamic-input-combobox.directive.ts | 78 +++++++++++++++++++ .../src/app/shared/models/combo-box.model.ts | 5 ++ .../shared/services/combo-box.service.spec.ts | 16 ++++ .../app/shared/services/combo-box.service.ts | 16 ++++ 10 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html index ca4fc7e171a..a7708aa496f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html @@ -87,6 +87,8 @@ [invalid]="form.controls.label.invalid && (form.controls.label.dirty)" [invalidText]="labelError" cdRequiredField="Label" + cdDynamicInputCombobox + (updatedItems)="data.labels = $event" i18n> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html index 778853b0b2b..99a7258edf9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html @@ -80,8 +80,10 @@ i18n-placeholder [appendInline]="true" [items]="labelsOption" - itemValueKey="value" + itemValueKey="content" id="labels" + cdDynamicInputCombobox + (updatedItems)="labelsOption = $event" i18n> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts index 4bc10c7dab9..6d8aa9e1286 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts @@ -5,12 +5,12 @@ import expand from 'brace-expansion'; import { HostService } from '~/app/shared/api/host.service'; import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; -import { SelectOption } from '~/app/shared/components/select/select-option.model'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdForm } from '~/app/shared/forms/cd-form'; import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { CdValidators } from '~/app/shared/forms/cd-validators'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { ComboBoxItem } from '~/app/shared/models/combo-box.model'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -32,7 +32,7 @@ export class HostFormComponent extends CdForm implements OnInit { allLabels: string[]; pageURL: string; hostPattern = false; - labelsOption: Array = []; + labelsOption: ComboBoxItem[] = []; messages = new SelectMessages({ empty: $localize`There are no labels.`, @@ -71,7 +71,7 @@ export class HostFormComponent extends CdForm implements OnInit { this.hostService.getLabels().subscribe((resp: string[]) => { const uniqueLabels = new Set(resp.concat(this.hostService.predefinedLabels)); this.labelsOption = Array.from(uniqueLabels).map((label) => { - return { enabled: true, name: label, content: label, selected: false, description: null }; + return { name: label, content: label }; }); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html index a32b6874707..f05ee7abe8c 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html @@ -83,10 +83,12 @@ [formControlName]="field.name" itemValueKey="content" [id]="field.name" - [items]="field?.typeConfig?.options" [invalid]="getError(field)" [invalidText]="getError(field)" [appendInline]="false" + cdDynamicInputCombobox + (updatedItems)="field.typeConfig.options = $event" + [items]="field?.typeConfig?.options" [cdRequiredField]="field?.required === true ? field.label : ''" i18n> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts index 5d3f93b9564..576628ae70f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts @@ -19,6 +19,7 @@ import { RequiredFieldDirective } from './required-field.directive'; import { ReactiveFormsModule } from '@angular/forms'; import { OptionalFieldDirective } from './optional-field.directive'; import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.directive'; +import { DynamicInputComboboxDirective } from './dynamic-input-combobox.directive'; @NgModule({ imports: [ReactiveFormsModule], @@ -40,7 +41,8 @@ import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.dir AuthStorageDirective, RequiredFieldDirective, OptionalFieldDirective, - DimlessBinaryPerMinuteDirective + DimlessBinaryPerMinuteDirective, + DynamicInputComboboxDirective ], exports: [ AutofocusDirective, @@ -60,7 +62,8 @@ import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.dir AuthStorageDirective, RequiredFieldDirective, OptionalFieldDirective, - DimlessBinaryPerMinuteDirective + DimlessBinaryPerMinuteDirective, + DynamicInputComboboxDirective ] }) export class DirectivesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts new file mode 100644 index 00000000000..ce376c93832 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts @@ -0,0 +1,70 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { DEBOUNCE_TIMER, DynamicInputComboboxDirective } from './dynamic-input-combobox.directive'; +import { Subject } from 'rxjs'; +import { Component, EventEmitter } from '@angular/core'; +import { ComboBoxItem } from '../models/combo-box.model'; + +@Component({ + template: `
`, +}) +class MockComponent { + items: ComboBoxItem[] = [{ content: 'Item1', name: 'Item1' }]; + searchSubject = new Subject(); + selectedItems: ComboBoxItem[] = []; + updatedItems = new EventEmitter(); +} + +describe('DynamicInputComboboxDirective', () => { + + let component: MockComponent; + let fixture: ComponentFixture; + let directive: DynamicInputComboboxDirective; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [DynamicInputComboboxDirective, MockComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MockComponent); + component = fixture.componentInstance; + + directive = fixture.debugElement.children[0].injector.get(DynamicInputComboboxDirective); + fixture.detectChanges(); + }); + + afterEach(() => { + directive.ngOnDestroy(); + }); + + it('should create an instance', () => { + expect(directive).toBeTruthy(); + }); + + it('should update items when input is given', fakeAsync(() => { + const newItem = 'NewItem'; + directive.onInput(newItem); + tick(DEBOUNCE_TIMER); + + expect(directive.items[0].content).toBe(newItem); + })); + + it('should not unselect selected items', fakeAsync(() => { + const selectedItems: ComboBoxItem[] = [{ + content: 'selectedItem', + name: 'selectedItem', + selected: true + }]; + + directive.items = selectedItems; + + directive.onSelected(selectedItems); + tick(DEBOUNCE_TIMER); + + directive.onInput(selectedItems[0].content); + tick(DEBOUNCE_TIMER); + + expect(directive.items[0].content).toBe(selectedItems[0].content); + expect(directive.items[0].selected).toBeTruthy(); + })) +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts new file mode 100644 index 00000000000..0f7d9941577 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts @@ -0,0 +1,78 @@ +import { Directive, Input, OnDestroy, OnInit, Output, EventEmitter, HostListener } from '@angular/core'; +import { ComboBoxItem } from '../models/combo-box.model'; +import { ComboBoxService } from '../services/combo-box.service'; +import { Subject, Subscription } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; + +export const DEBOUNCE_TIMER = 300; + +/** + * Directive to introduce adding custom items to the carbon combo-box component + * It takes the inputs of the combo-box and then append it with the searched item. + * Then it emits the updatedItems back to the element + */ +@Directive({ + selector: '[cdDynamicInputCombobox]' +}) +export class DynamicInputComboboxDirective implements OnInit, OnDestroy { + /** + * This input is the same as what we have in the element. + */ + @Input() items: ComboBoxItem[]; + + /** + * This will hold the items of the combo-box appended with the searched items. + */ + @Output() updatedItems: EventEmitter = new EventEmitter(); + + private searchSubscription: Subscription; + private searchSubject: Subject = new Subject(); + private selectedItems: ComboBoxItem[] = []; + + constructor( + private combBoxService: ComboBoxService + ) { } + + ngOnInit() { + this.searchSubscription = this.searchSubject + .pipe( + debounceTime(DEBOUNCE_TIMER), + distinctUntilChanged() + ) + .subscribe((searchString) => { + // Already selected items should be selected in the dropdown + // even if the items are updated again + this.items = this.items.map((item: ComboBoxItem) => { + const selected = this.selectedItems.some( + (selectedItem: ComboBoxItem) => selectedItem.content === item.content + ); + return { ...item, selected }; + }); + + const exists = this.items.some( + (item: ComboBoxItem) => item.content === searchString + ); + + if (!exists) { + this.items = this.items.concat({ content: searchString, name: searchString }); + } + this.updatedItems.emit(this.items ); + this.combBoxService.emit({ searchString }); + }); + } + + @HostListener('search', ['$event']) + onInput(event: string) { + if (event.length > 1) this.searchSubject.next(event); + } + + @HostListener('selected', ['$event']) + onSelected(event: ComboBoxItem[]) { + this.selectedItems = event; + } + + ngOnDestroy() { + this.searchSubscription.unsubscribe(); + this.searchSubject.complete(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts new file mode 100644 index 00000000000..9b87f5cd19f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts @@ -0,0 +1,5 @@ +export type ComboBoxItem = { + content: string; + name: string; + selected?: boolean; +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.spec.ts new file mode 100644 index 00000000000..c18eab040ad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ComboBoxService } from './combo-box.service'; + +describe('ComboBoxService', () => { + let service: ComboBoxService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ComboBoxService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.ts new file mode 100644 index 00000000000..bb193b18592 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ComboBoxService { + private searchSubject = new Subject<{ searchString: string }>(); + + constructor() { + } + + emit(value: { searchString: string }) { + this.searchSubject.next(value); + } +} -- 2.39.5