]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: add custom items to combo box
authorNizamudeen A <nia@redhat.com>
Fri, 27 Sep 2024 05:30:17 +0000 (11:00 +0530)
committerNizamudeen A <nia@redhat.com>
Thu, 13 Mar 2025 04:46:45 +0000 (10:16 +0530)
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 <nia@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-form/cephfs-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dynamic-input-combobox.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/combo-box.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/combo-box.service.ts [new file with mode: 0644]

index ca4fc7e171a171d3466ca13bff450698ea05cace..a7708aa496fd557b0d2e672d4d1684812734b256 100644 (file)
@@ -87,6 +87,8 @@
                            [invalid]="form.controls.label.invalid && (form.controls.label.dirty)"
                            [invalidText]="labelError"
                            cdRequiredField="Label"
+                           cdDynamicInputCombobox
+                           (updatedItems)="data.labels = $event"
                            i18n>
               <cds-dropdown-list></cds-dropdown-list>
             </cds-combo-box>
index 778853b0b2b30744f8751c7d0b1e03d880a6774e..99a7258edf96bc638b6faf246c9cc73c382cda45 100644 (file)
                        i18n-placeholder
                        [appendInline]="true"
                        [items]="labelsOption"
-                       itemValueKey="value"
+                       itemValueKey="content"
                        id="labels"
+                       cdDynamicInputCombobox
+                       (updatedItems)="labelsOption = $event"
                        i18n>
           <cds-dropdown-list></cds-dropdown-list>
         </cds-combo-box>
index 4bc10c7dab9818b5b90fb6011aa371191556489e..6d8aa9e128639200c192458488a5681c97019929 100644 (file)
@@ -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<SelectOption> = [];
+  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 };
       });
     });
   }
index a32b687470752165dc0673d14fe60dd2f17630e6..f05ee7abe8c0feb679d923d7bdadc624e5646914 100755 (executable)
                          [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>
             <cds-dropdown-list></cds-dropdown-list>
index 5d3f93b956447d811e1da6e9dcb96ab84b29f221..576628ae70f4d7876ad52229bfb8539b5511078c 100644 (file)
@@ -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 (file)
index 0000000..ce376c9
--- /dev/null
@@ -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: `<div cdDynamicInputCombobox
+                  [items]="[]"></div>`,
+})
+class MockComponent {
+  items: ComboBoxItem[] = [{ content: 'Item1', name: 'Item1' }];
+  searchSubject = new Subject<string>();
+  selectedItems: ComboBoxItem[] = [];
+  updatedItems = new EventEmitter<ComboBoxItem[]>();
+}
+
+describe('DynamicInputComboboxDirective', () => {
+
+  let component: MockComponent;
+  let fixture: ComponentFixture<MockComponent>;
+  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 (file)
index 0000000..0f7d994
--- /dev/null
@@ -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 <cds-combobox> element
+ */
+@Directive({
+  selector: '[cdDynamicInputCombobox]'
+})
+export class DynamicInputComboboxDirective implements OnInit, OnDestroy {
+  /**
+   * This input is the same as what we have in the <cds-combobox> element.
+   */
+  @Input() items: ComboBoxItem[];
+
+  /**
+   * This will hold the items of the combo-box appended with the searched items.
+   */
+  @Output() updatedItems: EventEmitter<ComboBoxItem[]> = new EventEmitter();
+
+  private searchSubscription: Subscription;
+  private searchSubject: Subject<string> = 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 (file)
index 0000000..9b87f5c
--- /dev/null
@@ -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 (file)
index 0000000..c18eab0
--- /dev/null
@@ -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 (file)
index 0000000..bb193b1
--- /dev/null
@@ -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);
+  }
+}