From fb0f80b6a3c5c15e37307b1a60323f858cac18f4 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 16 Sep 2024 16:08:07 +0300 Subject: [PATCH 01/13] feat(combo): filter out non-existent items from selection --- .../src/lib/combo/combo.component.spec.ts | 75 +++++++++++++++++-- .../src/lib/combo/combo.component.ts | 62 ++++++++++++--- 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index c0c37334f3d..b9963f3512e 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -105,6 +105,7 @@ describe('igxCombo', () => { spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); expect(mockInjector.get).toHaveBeenCalledWith(NgControl, null); + combo.data = [{ id: 'test', name: 'test' }]; combo.registerOnChange(mockNgControl.registerOnChangeCb); combo.registerOnTouched(mockNgControl.registerOnTouchedCb); @@ -113,8 +114,8 @@ describe('igxCombo', () => { mockSelection.get.and.returnValue(new Set(['test'])); spyOnProperty(combo, 'isRemote').and.returnValue(false); combo.writeValue(['test']); - expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); - expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['test'], true); + expect(mockNgControl.registerOnChangeCb).toHaveBeenCalled(); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); expect(combo.displayValue).toEqual('test'); expect(combo.value).toEqual(['test']); @@ -125,10 +126,11 @@ describe('igxCombo', () => { expect(combo.disabled).toBe(false); // OnChange callback + combo.data = [{ id: 'simpleValue', name: 'simpleValue' }]; mockSelection.add_items.and.returnValue(new Set(['simpleValue'])); combo.select(['simpleValue']); - expect(mockSelection.add_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], undefined); - expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], true); + expect(mockSelection.add_items).toHaveBeenCalledWith(combo.id, [], undefined); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(['simpleValue']); // OnTouched callback @@ -258,7 +260,8 @@ describe('igxCombo', () => { ); spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); - combo.data = data; + combo.data = [{ id: 'EXAMPLE', name: 'Example' }]; + combo.valueKey = 'id'; mockSelection.select_items.calls.reset(); spyOnProperty(combo, 'isRemote').and.returnValue(false); combo.writeValue(['EXAMPLE']); @@ -2604,6 +2607,38 @@ describe('igxCombo', () => { expect(selectionSpy).toHaveBeenCalledWith(expectedResults); expect(input.nativeElement.value).toEqual(expectedDisplayText); }); + it('should only select and display valid values when programmatically selecting invalid items with ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(ComboInvalidValuesComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + const component = fixture.componentInstance; + tick(100); + + component.selectedItems = ['SF', 'LA', 'NY']; // 'SF' is invalid, 'LA' and 'NY' are valid + fixture.detectChanges(); + tick(100); + + expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); + expect(combo.value).toEqual(['LA', 'NY']); + expect(combo.displayValue).toEqual('Los Angeles, New York'); + expect(component.selectedItems).toEqual(['LA', 'NY']); + })); + it('should only select and display valid values when selecting invalid items programmatically using select', fakeAsync(() => { + fixture = TestBed.createComponent(ComboInvalidValuesComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.combo; + const component = fixture.componentInstance; + tick(100); + + combo.select(['SF', 'LA', 'NY']); // 'SF' is invalid, 'LA' and 'NY' are valid + fixture.detectChanges(); + tick(100); + + expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); + expect(combo.value).toEqual(['LA', 'NY']); + expect(combo.displayValue).toEqual('Los Angeles, New York'); + expect(component.selectedItems).toEqual(['LA', 'NY']); + })); }); describe('Grouping tests: ', () => { beforeEach(() => { @@ -4087,3 +4122,33 @@ export class ComboWithIdComponent { ]; } } + +@Component({ + template: ` + + `, + standalone: true, + imports: [IgxComboComponent, FormsModule] +}) +export class ComboInvalidValuesComponent implements OnInit { + @ViewChild(IgxComboComponent, { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + + public cities: any[] = []; + public selectedItems: any[] = []; + + constructor(private cdr: ChangeDetectorRef) { } + + public ngOnInit() { + this.cities = [ + { name: 'New York', id: 'NY' }, + { name: 'Los Angeles', id: 'LA' }, + { name: 'Chicago', id: 'CHI' } + ]; + this.cdr.detectChanges(); + } +} diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index 2d3997eb75c..787283a06fb 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -1,6 +1,6 @@ import { DOCUMENT, NgClass, NgIf, NgTemplateOutlet } from '@angular/common'; import { - AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, OnDestroy, + AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChanges, Optional, Inject, Injector, ViewChild, Input, Output, EventEmitter, HostListener, DoCheck, booleanAttribute } from '@angular/core'; @@ -124,7 +124,7 @@ const diffInSets = (set1: Set, set2: Set): any[] => { ] }) export class IgxComboComponent extends IgxComboBaseDirective implements AfterViewInit, ControlValueAccessor, OnInit, - OnDestroy, DoCheck, EditorProvider { + OnDestroy, DoCheck, EditorProvider, OnChanges { /** * Whether the combo's search box should be focused after the dropdown is opened. * When `false`, the combo's list item container will be focused instead @@ -251,12 +251,12 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie * @hidden @internal */ public writeValue(value: any[]): void { - const selection = Array.isArray(value) ? value.filter(x => x !== undefined) : []; + this._value = Array.isArray(value) ? value.filter(x => x !== undefined) : []; const oldSelection = this.selection; - this.selectionService.select_items(this.id, selection, true); - this.cdr.markForCheck(); - this._displayValue = this.createDisplayText(this.selection, oldSelection); - this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; + + if (this.data && this.data.length > 0) { + this.applySelection(oldSelection); + } } /** @hidden @internal */ @@ -267,6 +267,14 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } } + /** @hidden @internal */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes['data'] && this.data && this.data.length > 0 && this._value && this._value.length) { + const oldSelection = this.selection; + this.applySelection(oldSelection); + } + } + /** * @hidden */ @@ -309,8 +317,16 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie * ``` */ public select(newItems: Array, clearCurrentSelection?: boolean, event?: Event) { - if (newItems) { - const newSelection = this.selectionService.add_items(this.id, newItems, clearCurrentSelection); + if (!newItems) { + return; + } + + if (this.isRemote || (this.data && this.data.length)) { + const validItems = !this.isRemote + ? newItems.filter(item => this.isItemInData(item)) + : newItems; + + const newSelection = this.selectionService.add_items(this.id, validItems, clearCurrentSelection); this.setSelection(newSelection, event); } } @@ -469,4 +485,32 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie selection.join(', '); return value; } + + private applySelection(oldSelection: any[]): void { + const selection = this._value || []; + const filteredSelection = this.isRemote ? selection : selection.filter(item => this.isItemInData(item)); + + this.selectionService.select_items(this.id, filteredSelection, true); + this.cdr.markForCheck(); + + this._displayValue = this.createDisplayText(this.selection, oldSelection); + this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; + + if (this._onChangeCallback) { + this._onChangeCallback(this._value); + } + } + + private isItemInData(item: any): boolean { + return this.data.some(dataItem => { + const dataValue = this.valueKey ? dataItem[this.valueKey] : dataItem; + const itemValue = this.valueKey ? item : item; + + if (Number.isNaN(dataValue) && Number.isNaN(itemValue)) { + return true; + } + + return dataValue === itemValue; + }); + } } From a4ca004b9d77c620775a403ed0d83ade95b49f01 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Thu, 19 Sep 2024 17:44:14 +0300 Subject: [PATCH 02/13] feat(combo): refine selection logic to handle unmatched values --- .../src/lib/combo/combo.component.spec.ts | 5 +-- .../src/lib/combo/combo.component.ts | 26 ++++++------ .../igniteui-angular/src/lib/core/utils.ts | 41 +++++++++++++++++++ 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index b9963f3512e..f424090ed90 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -114,7 +114,7 @@ describe('igxCombo', () => { mockSelection.get.and.returnValue(new Set(['test'])); spyOnProperty(combo, 'isRemote').and.returnValue(false); combo.writeValue(['test']); - expect(mockNgControl.registerOnChangeCb).toHaveBeenCalled(); + expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); expect(combo.displayValue).toEqual('test'); expect(combo.value).toEqual(['test']); @@ -2621,7 +2621,6 @@ describe('igxCombo', () => { expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); expect(combo.value).toEqual(['LA', 'NY']); expect(combo.displayValue).toEqual('Los Angeles, New York'); - expect(component.selectedItems).toEqual(['LA', 'NY']); })); it('should only select and display valid values when selecting invalid items programmatically using select', fakeAsync(() => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); @@ -3370,7 +3369,7 @@ describe('igxCombo', () => { expect(combo.valid).toEqual(IgxInputState.INVALID); expect(combo.comboInput.valid).toEqual(IgxInputState.INVALID); - combo.select([combo.dropdown.items[0], combo.dropdown.items[1]]); + combo.select([combo.dropdown.items[0].value, combo.dropdown.items[1].value]); expect(combo.valid).toEqual(IgxInputState.VALID); expect(combo.comboInput.valid).toEqual(IgxInputState.VALID); diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index 787283a06fb..9c9e8f14935 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -7,7 +7,7 @@ import { import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; import { IgxSelectionAPIService } from '../core/selection'; -import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs } from '../core/utils'; +import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs, areObjectsEqual } from '../core/utils'; import { IgxStringFilteringOperand, IgxBooleanFilteringOperand } from '../data-operations/filtering-condition'; import { FilteringLogic } from '../data-operations/filtering-expression.interface'; import { IgxForOfDirective } from '../directives/for-of/for_of.directive'; @@ -253,7 +253,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie public writeValue(value: any[]): void { this._value = Array.isArray(value) ? value.filter(x => x !== undefined) : []; const oldSelection = this.selection; - + if (this.data && this.data.length > 0) { this.applySelection(oldSelection); } @@ -320,12 +320,12 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie if (!newItems) { return; } - + if (this.isRemote || (this.data && this.data.length)) { const validItems = !this.isRemote ? newItems.filter(item => this.isItemInData(item)) : newItems; - + const newSelection = this.selectionService.add_items(this.id, validItems, clearCurrentSelection); this.setSelection(newSelection, event); } @@ -489,27 +489,25 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie private applySelection(oldSelection: any[]): void { const selection = this._value || []; const filteredSelection = this.isRemote ? selection : selection.filter(item => this.isItemInData(item)); - + this.selectionService.select_items(this.id, filteredSelection, true); this.cdr.markForCheck(); - + this._displayValue = this.createDisplayText(this.selection, oldSelection); this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; - - if (this._onChangeCallback) { - this._onChangeCallback(this._value); - } } private isItemInData(item: any): boolean { - return this.data.some(dataItem => { - const dataValue = this.valueKey ? dataItem[this.valueKey] : dataItem; - const itemValue = this.valueKey ? item : item; + if (!this.valueKey) { + return this.data.some(dataItem => areObjectsEqual(dataItem, item)); + } + return this.data.some(dataItem => { + const dataValue = dataItem[this.valueKey]; + const itemValue = item; if (Number.isNaN(dataValue) && Number.isNaN(itemValue)) { return true; } - return dataValue === itemValue; }); } diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index 4068f4cb29b..d6f7c9c6da6 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -93,6 +93,47 @@ export const mergeObjects = (obj1: any, obj2: any): any => mergeWith(obj1, obj2, } }); +/** + * Recursively checks if two objects are deeply equal. + * Handles circular references by keeping track of seen objects. + * + * @param obj1 First object to compare + * @param obj2 Second object to compare + * @param seen Set of already visited objects to handle circular references + * @returns true if objects are equal, false otherwise + * @hidden + */ +export const areObjectsEqual = (obj1: any, obj2: any, seen = new Set()): boolean => { + if (obj1 === obj2) return true; + + if ( + typeof obj1 !== 'object' || + obj1 === null || + typeof obj2 !== 'object' || + obj2 === null + ) { + return obj1 === obj2; + } + + if (seen.has(obj1) || seen.has(obj2)) { + return false; + } + seen.add(obj1); + seen.add(obj2); + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + if (!Object.prototype.hasOwnProperty.call(obj2, key)) return false; + if (!areObjectsEqual(obj1[key], obj2[key], seen)) return false; + } + + return true; +}; + /** * Creates deep clone of provided value. * Supports primitive values, dates and objects. From 3f10d0902a50ef00acedd6fbe88220dea84195ce Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Thu, 19 Sep 2024 17:47:11 +0300 Subject: [PATCH 03/13] test(combo): remove unnecessary check --- projects/igniteui-angular/src/lib/combo/combo.component.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index f424090ed90..61781fc0bf5 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -2636,7 +2636,6 @@ describe('igxCombo', () => { expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); expect(combo.value).toEqual(['LA', 'NY']); expect(combo.displayValue).toEqual('Los Angeles, New York'); - expect(component.selectedItems).toEqual(['LA', 'NY']); })); }); describe('Grouping tests: ', () => { From d37b2881229fc624e66d163ac50ff61141000fe4 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Thu, 19 Sep 2024 17:57:59 +0300 Subject: [PATCH 04/13] test(combo): remove unused 'component' variable --- projects/igniteui-angular/src/lib/combo/combo.component.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index 61781fc0bf5..55ecc47c17f 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -2626,7 +2626,6 @@ describe('igxCombo', () => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); fixture.detectChanges(); combo = fixture.componentInstance.combo; - const component = fixture.componentInstance; tick(100); combo.select(['SF', 'LA', 'NY']); // 'SF' is invalid, 'LA' and 'NY' are valid From 6dd6d444dbba77760fdadb5aab59219fbfe971bc Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Wed, 2 Oct 2024 13:31:57 +0300 Subject: [PATCH 05/13] feat(combo): remove redundant comparison function and simplify selection logic --- .../src/lib/combo/combo.component.spec.ts | 17 ++++---- .../src/lib/combo/combo.component.ts | 43 +++++++++---------- .../igniteui-angular/src/lib/core/utils.ts | 41 ------------------ 3 files changed, 30 insertions(+), 71 deletions(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index 55ecc47c17f..58130916317 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -106,6 +106,7 @@ describe('igxCombo', () => { combo.ngOnInit(); expect(mockInjector.get).toHaveBeenCalledWith(NgControl, null); combo.data = [{ id: 'test', name: 'test' }]; + combo.valueKey = 'id'; combo.registerOnChange(mockNgControl.registerOnChangeCb); combo.registerOnTouched(mockNgControl.registerOnTouchedCb); @@ -115,7 +116,7 @@ describe('igxCombo', () => { spyOnProperty(combo, 'isRemote').and.returnValue(false); combo.writeValue(['test']); expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled(); - expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['test'], true); expect(combo.displayValue).toEqual('test'); expect(combo.value).toEqual(['test']); @@ -127,10 +128,11 @@ describe('igxCombo', () => { // OnChange callback combo.data = [{ id: 'simpleValue', name: 'simpleValue' }]; + combo.valueKey = 'id'; mockSelection.add_items.and.returnValue(new Set(['simpleValue'])); combo.select(['simpleValue']); - expect(mockSelection.add_items).toHaveBeenCalledWith(combo.id, [], undefined); - expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); + expect(mockSelection.add_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], undefined); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['simpleValue'], true); expect(mockNgControl.registerOnChangeCb).toHaveBeenCalledWith(['simpleValue']); // OnTouched callback @@ -260,8 +262,7 @@ describe('igxCombo', () => { ); spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); - combo.data = [{ id: 'EXAMPLE', name: 'Example' }]; - combo.valueKey = 'id'; + combo.data = data; mockSelection.select_items.calls.reset(); spyOnProperty(combo, 'isRemote').and.returnValue(false); combo.writeValue(['EXAMPLE']); @@ -270,7 +271,7 @@ describe('igxCombo', () => { // Calling "select_items" through the writeValue accessor should clear the previous values; // Select items is called with the invalid value and it is written in selection, though no item is selected // Controlling the selection is up to the user - expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['EXAMPLE'], true); + expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); combo.writeValue(combo.data[0]); // When value key is specified, the item's value key is stored in the selection expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true); @@ -2612,7 +2613,7 @@ describe('igxCombo', () => { fixture.detectChanges(); combo = fixture.componentInstance.combo; const component = fixture.componentInstance; - tick(100); + tick(); component.selectedItems = ['SF', 'LA', 'NY']; // 'SF' is invalid, 'LA' and 'NY' are valid fixture.detectChanges(); @@ -2626,7 +2627,7 @@ describe('igxCombo', () => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); fixture.detectChanges(); combo = fixture.componentInstance.combo; - tick(100); + tick(); combo.select(['SF', 'LA', 'NY']); // 'SF' is invalid, 'LA' and 'NY' are valid fixture.detectChanges(); diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index 9c9e8f14935..6cefd4f6973 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -7,7 +7,8 @@ import { import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; import { IgxSelectionAPIService } from '../core/selection'; -import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs, areObjectsEqual } from '../core/utils'; +import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs } from '../core/utils'; +import { isEqual } from 'lodash-es'; import { IgxStringFilteringOperand, IgxBooleanFilteringOperand } from '../data-operations/filtering-condition'; import { FilteringLogic } from '../data-operations/filtering-expression.interface'; import { IgxForOfDirective } from '../directives/for-of/for_of.directive'; @@ -255,7 +256,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie const oldSelection = this.selection; if (this.data && this.data.length > 0) { - this.applySelection(oldSelection); + this.setSelection(new Set(this._value), undefined, false); } } @@ -270,8 +271,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie /** @hidden @internal */ public ngOnChanges(changes: SimpleChanges): void { if (changes['data'] && this.data && this.data.length > 0 && this._value && this._value.length) { - const oldSelection = this.selection; - this.applySelection(oldSelection); + this.setSelection(new Set(this._value), undefined, false); } } @@ -435,15 +435,18 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } } - protected setSelection(selection: Set, event?: Event): void { + protected setSelection(selection: Set, event?: Event, emitEvent: boolean = true): void { + const filteredSelection = this.isRemote ? Array.from(selection) : Array.from(selection).filter(item => this.isItemInData(item)); + const currentSelection = this.selectionService.get(this.id); - const removed = this.convertKeysToItems(diffInSets(currentSelection, selection)); - const added = this.convertKeysToItems(diffInSets(selection, currentSelection)); - const newValue = Array.from(selection); + const removed = this.convertKeysToItems(diffInSets(currentSelection, new Set(filteredSelection))); + const added = this.convertKeysToItems(diffInSets(new Set(filteredSelection), currentSelection)); + const newValue = filteredSelection; const oldValue = Array.from(currentSelection || []); const newSelection = this.convertKeysToItems(newValue); const oldSelection = this.convertKeysToItems(oldValue); const displayText = this.createDisplayText(this.convertKeysToItems(newValue), oldValue); + const args: IComboSelectionChangingEventArgs = { newValue, oldValue, @@ -456,7 +459,11 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie displayText, cancel: false }; - this.selectionChanging.emit(args); + + if (emitEvent) { + this.selectionChanging.emit(args); + } + if (!args.cancel) { this.selectionService.select_items(this.id, args.newValue, true); this._value = args.newValue; @@ -465,7 +472,10 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } else { this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } - this._onChangeCallback(args.newValue); + this.cdr.markForCheck(); + if (emitEvent) { + this._onChangeCallback(args.newValue); + } } else if (this.isRemote) { this.registerRemoteEntries(diffInSets(selection, currentSelection), false); } @@ -486,20 +496,9 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie return value; } - private applySelection(oldSelection: any[]): void { - const selection = this._value || []; - const filteredSelection = this.isRemote ? selection : selection.filter(item => this.isItemInData(item)); - - this.selectionService.select_items(this.id, filteredSelection, true); - this.cdr.markForCheck(); - - this._displayValue = this.createDisplayText(this.selection, oldSelection); - this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection; - } - private isItemInData(item: any): boolean { if (!this.valueKey) { - return this.data.some(dataItem => areObjectsEqual(dataItem, item)); + return this.data.some(dataItem => isEqual(dataItem, item)); } return this.data.some(dataItem => { diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index d6f7c9c6da6..4068f4cb29b 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -93,47 +93,6 @@ export const mergeObjects = (obj1: any, obj2: any): any => mergeWith(obj1, obj2, } }); -/** - * Recursively checks if two objects are deeply equal. - * Handles circular references by keeping track of seen objects. - * - * @param obj1 First object to compare - * @param obj2 Second object to compare - * @param seen Set of already visited objects to handle circular references - * @returns true if objects are equal, false otherwise - * @hidden - */ -export const areObjectsEqual = (obj1: any, obj2: any, seen = new Set()): boolean => { - if (obj1 === obj2) return true; - - if ( - typeof obj1 !== 'object' || - obj1 === null || - typeof obj2 !== 'object' || - obj2 === null - ) { - return obj1 === obj2; - } - - if (seen.has(obj1) || seen.has(obj2)) { - return false; - } - seen.add(obj1); - seen.add(obj2); - - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) return false; - - for (const key of keys1) { - if (!Object.prototype.hasOwnProperty.call(obj2, key)) return false; - if (!areObjectsEqual(obj1[key], obj2[key], seen)) return false; - } - - return true; -}; - /** * Creates deep clone of provided value. * Supports primitive values, dates and objects. From ccff343b0fbdfee00cfd158e63b8a3760193912f Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Wed, 2 Oct 2024 14:34:04 +0300 Subject: [PATCH 06/13] fix(combo): remove unused variable oldSelection --- projects/igniteui-angular/src/lib/combo/combo.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index 6cefd4f6973..e0933b480b6 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -253,7 +253,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie */ public writeValue(value: any[]): void { this._value = Array.isArray(value) ? value.filter(x => x !== undefined) : []; - const oldSelection = this.selection; if (this.data && this.data.length > 0) { this.setSelection(new Set(this._value), undefined, false); From 510fb1f9fbdc82075b52252877f1248b568d70d8 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Thu, 3 Oct 2024 15:51:13 +0300 Subject: [PATCH 07/13] chore(changelog): filtering of non-existent values in IgxCombo selections --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc551d7dca..2ea9d06bffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,10 +43,11 @@ For Firefox users, we provide limited scrollbar styling options through the foll - **Behavioral Changes** - the `keyboardSupport` input property now defaults to `false`. - **Deprecation** - the `keyboardSupport` input property has been deprecated and will be removed in a future version. Keyboard navigation with `ArrowLeft`, `ArrowRight`, `Home`, and `End` keys will be supported when focusing the indicators' container via ` Tab`/`Shift+Tab`. - - `IgxBadge` - **Breaking Change** The `$border-width` property has been removed from the badge theme. - New outlined variant of the badge component has been added. Users can switch to `outlined` by adding the newly created `outlined` property to a badge. +- `IgxCombo` + - Introduced the ability to automatically filter out and exclude values that are not in the data when programmatically setting selected items. Now, only values that match the bound data will appear in the selection. ## 18.1.0 ### New Features From 2b41101e89e19c03b4c570f75215cd003426b995 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Fri, 4 Oct 2024 17:34:45 +0300 Subject: [PATCH 08/13] feat(combo): simplify selection logic --- CHANGELOG.md | 5 ++-- .../src/lib/combo/combo.component.spec.ts | 5 +--- .../src/lib/combo/combo.component.ts | 28 +++++++++---------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea9d06bffd..7716f3caa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ All notable changes for each version of this project will be documented in this - Introduced ability for Simple Combo to automatically select and retain valid input on "Tab" press enhancing user experience by streamlining data entry and reducing the need for manual selection improving form navigation. - `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` - To streamline the sorting of columns with custom formats, a new `FormattedValuesSortingStrategy` has been introduced. This strategy simplifies the sorting process by allowing direct sorting based on formatted values, eliminating the need to extend the `DefaultSortingStrategy` or implement a custom `ISortingStrategy`. This enhancement improves the ease of handling sorting with custom column formatters. - - `IgxCarousel` - Added support for vertical alignment. Can be configured via the `vertical` property. Defaults to `false`. - Added support for showing/hiding the indicator controls (dots). Can be configured via the `indicators` property. Defaults to `true`. +- `IgxCombo` + - Introduced the ability to automatically filter out and exclude values that are not in the data when programmatically setting selected items. This behavior specifically applies when the `combo` is bound to local data, as querying the entire data source to verify value presence is not feasible in remote scenarios. #### Scrollbar: New CSS variables @@ -46,8 +47,6 @@ For Firefox users, we provide limited scrollbar styling options through the foll - `IgxBadge` - **Breaking Change** The `$border-width` property has been removed from the badge theme. - New outlined variant of the badge component has been added. Users can switch to `outlined` by adding the newly created `outlined` property to a badge. -- `IgxCombo` - - Introduced the ability to automatically filter out and exclude values that are not in the data when programmatically setting selected items. Now, only values that match the bound data will appear in the selection. ## 18.1.0 ### New Features diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index 58130916317..47904c320f0 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -2613,11 +2613,10 @@ describe('igxCombo', () => { fixture.detectChanges(); combo = fixture.componentInstance.combo; const component = fixture.componentInstance; - tick(); component.selectedItems = ['SF', 'LA', 'NY']; // 'SF' is invalid, 'LA' and 'NY' are valid fixture.detectChanges(); - tick(100); + tick(); expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); expect(combo.value).toEqual(['LA', 'NY']); @@ -2627,11 +2626,9 @@ describe('igxCombo', () => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); fixture.detectChanges(); combo = fixture.componentInstance.combo; - tick(); combo.select(['SF', 'LA', 'NY']); // 'SF' is invalid, 'LA' and 'NY' are valid fixture.detectChanges(); - tick(100); expect(combo.selection).toEqual([{ name: 'Los Angeles', id: 'LA' }, { name: 'New York', id: 'NY' }]); expect(combo.value).toEqual(['LA', 'NY']); diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index e0933b480b6..a0878890270 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -255,7 +255,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie this._value = Array.isArray(value) ? value.filter(x => x !== undefined) : []; if (this.data && this.data.length > 0) { - this.setSelection(new Set(this._value), undefined, false); + this.setSelection(new Set(this._value), undefined, false, false); } } @@ -270,7 +270,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie /** @hidden @internal */ public ngOnChanges(changes: SimpleChanges): void { if (changes['data'] && this.data && this.data.length > 0 && this._value && this._value.length) { - this.setSelection(new Set(this._value), undefined, false); + this.setSelection(new Set(this._value), undefined, false, true); } } @@ -320,14 +320,13 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie return; } - if (this.isRemote || (this.data && this.data.length)) { - const validItems = !this.isRemote - ? newItems.filter(item => this.isItemInData(item)) - : newItems; - - const newSelection = this.selectionService.add_items(this.id, validItems, clearCurrentSelection); - this.setSelection(newSelection, event); + let validItems = newItems; + if (!this.isRemote && this.data && this.data.length) { + validItems = newItems.filter(item => this.isItemInData(item)); } + + const newSelection = this.selectionService.add_items(this.id, validItems, clearCurrentSelection); + this.setSelection(newSelection, event); } /** @@ -434,13 +433,13 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } } - protected setSelection(selection: Set, event?: Event, emitEvent: boolean = true): void { + protected setSelection(selection: Set, event?: Event, emit: boolean = true, updateModel: boolean = true): void { const filteredSelection = this.isRemote ? Array.from(selection) : Array.from(selection).filter(item => this.isItemInData(item)); + const newValue = filteredSelection; const currentSelection = this.selectionService.get(this.id); const removed = this.convertKeysToItems(diffInSets(currentSelection, new Set(filteredSelection))); const added = this.convertKeysToItems(diffInSets(new Set(filteredSelection), currentSelection)); - const newValue = filteredSelection; const oldValue = Array.from(currentSelection || []); const newSelection = this.convertKeysToItems(newValue); const oldSelection = this.convertKeysToItems(oldValue); @@ -459,7 +458,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie cancel: false }; - if (emitEvent) { + if (emit) { this.selectionChanging.emit(args); } @@ -471,8 +470,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } else { this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } - this.cdr.markForCheck(); - if (emitEvent) { + if (updateModel) { this._onChangeCallback(args.newValue); } } else if (this.isRemote) { @@ -503,6 +501,8 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie return this.data.some(dataItem => { const dataValue = dataItem[this.valueKey]; const itemValue = item; + // Treat NaN values as equal (since NaN !== NaN in regular comparisons) + // to ensure we support all falsy comparisons correctly if (Number.isNaN(dataValue) && Number.isNaN(itemValue)) { return true; } From a7dcb86e7be5195f56f4551a42a36198371cb7f6 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 14 Oct 2024 16:20:57 +0300 Subject: [PATCH 09/13] chore(changelog): update with simple combo details --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f48b0b5199..08713993f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes for each version of this project will be documented in this - `IgxCarousel` - Added support for vertical alignment. Can be configured via the `vertical` property. Defaults to `false`. - Added support for showing/hiding the indicator controls (dots). Can be configured via the `indicators` property. Defaults to `true`. -- `IgxCombo` +- `IgxCombo`, `IgxSimpleCombo` - Introduced the ability to automatically filter out and exclude values that are not in the data when programmatically setting selected items. This behavior specifically applies when the `combo` is bound to local data, as querying the entire data source to verify value presence is not feasible in remote scenarios. ### Themes From 428b0ca72574c4610072fdaab3ad72625b523281 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 14 Oct 2024 16:22:10 +0300 Subject: [PATCH 10/13] feat(combo, simple-combo): move isItemInData to shared combo common --- .../src/lib/combo/combo.common.ts | 19 +++++++++++++++++- .../src/lib/combo/combo.component.ts | 20 +------------------ 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.common.ts b/projects/igniteui-angular/src/lib/combo/combo.common.ts index 3df2190ee8d..7e049ae96ae 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.common.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.common.ts @@ -1357,6 +1357,23 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh return Object.keys(this._remoteSelection).map(e => this._remoteSelection[e]).join(', '); } + protected isItemInData(item: any): boolean { + if (!this.valueKey) { + return this.data.some(dataItem => isEqual(dataItem, item)); + } + + return this.data.some(dataItem => { + const dataValue = dataItem[this.valueKey]; + const itemValue = item; + // Treat NaN values as equal (since NaN !== NaN in regular comparisons) + // to ensure we support all falsy comparisons correctly + if (Number.isNaN(dataValue) && Number.isNaN(itemValue)) { + return true; + } + return dataValue === itemValue; + }); + } + protected get required(): boolean { if (this.ngControl && this.ngControl.control && this.ngControl.control.validator) { // Run the validation with empty object to check if required is enabled. @@ -1381,6 +1398,6 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh public abstract writeValue(value: any): void; - protected abstract setSelection(newSelection: Set, event?: Event): void; + protected abstract setSelection(newSelection: Set, event?: Event, emit?: boolean, updateModel?: boolean): void; protected abstract createDisplayText(newSelection: any[], oldSelection: any[]); } diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.ts b/projects/igniteui-angular/src/lib/combo/combo.component.ts index a0878890270..22525d663ad 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.ts @@ -8,7 +8,6 @@ import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/f import { IgxSelectionAPIService } from '../core/selection'; import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs } from '../core/utils'; -import { isEqual } from 'lodash-es'; import { IgxStringFilteringOperand, IgxBooleanFilteringOperand } from '../data-operations/filtering-condition'; import { FilteringLogic } from '../data-operations/filtering-expression.interface'; import { IgxForOfDirective } from '../directives/for-of/for_of.directive'; @@ -326,7 +325,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } const newSelection = this.selectionService.add_items(this.id, validItems, clearCurrentSelection); - this.setSelection(newSelection, event); + this.setSelection(newSelection, event, true, true); } /** @@ -492,21 +491,4 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie selection.join(', '); return value; } - - private isItemInData(item: any): boolean { - if (!this.valueKey) { - return this.data.some(dataItem => isEqual(dataItem, item)); - } - - return this.data.some(dataItem => { - const dataValue = dataItem[this.valueKey]; - const itemValue = item; - // Treat NaN values as equal (since NaN !== NaN in regular comparisons) - // to ensure we support all falsy comparisons correctly - if (Number.isNaN(dataValue) && Number.isNaN(itemValue)) { - return true; - } - return dataValue === itemValue; - }); - } } From 2a65f7f79cbe8f85ee98392dd1e97121e4586dad Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 14 Oct 2024 16:22:27 +0300 Subject: [PATCH 11/13] test(combo): update test titles for clarity --- .../igniteui-angular/src/lib/combo/combo.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index 47904c320f0..87a23c04e23 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -2608,7 +2608,7 @@ describe('igxCombo', () => { expect(selectionSpy).toHaveBeenCalledWith(expectedResults); expect(input.nativeElement.value).toEqual(expectedDisplayText); }); - it('should only select and display valid values when programmatically selecting invalid items with ngModel', fakeAsync(() => { + it('should only select and display existing values in the data when programmatically selecting non existing items with ngModel', fakeAsync(() => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); fixture.detectChanges(); combo = fixture.componentInstance.combo; @@ -2622,7 +2622,7 @@ describe('igxCombo', () => { expect(combo.value).toEqual(['LA', 'NY']); expect(combo.displayValue).toEqual('Los Angeles, New York'); })); - it('should only select and display valid values when selecting invalid items programmatically using select', fakeAsync(() => { + it('should only select and display existing values in the data when programmatically selecting non existing items with ngModel', fakeAsync(() => { fixture = TestBed.createComponent(ComboInvalidValuesComponent); fixture.detectChanges(); combo = fixture.componentInstance.combo; From b68d3e1d2c6718b9265cef8ede33d40a5064c0ba Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 14 Oct 2024 16:22:57 +0300 Subject: [PATCH 12/13] test(simple-combo): add enhancement tests and improve test logic --- .../simple-combo.component.spec.ts | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts index 60d146863cc..8e9e1ebd2f8 100644 --- a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts @@ -821,7 +821,7 @@ describe('IgxSimpleCombo', () => { const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); expect(clearButton).toBeNull(); - comboComponent.selectedItem = { id: 1, text: 'Option 1' }; + comboComponent.selectedItem = 1; fixture.detectChanges(); fixture.whenStable().then(() => { @@ -838,6 +838,9 @@ describe('IgxSimpleCombo', () => { tick(); fixture.detectChanges(); + comboComponent.items = [{ id: null, text: '' }]; + fixture.detectChanges(); + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); expect(clearButton).toBeNull(); @@ -862,7 +865,7 @@ describe('IgxSimpleCombo', () => { const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); expect(clearButton).toBeNull(); - comboComponent.formControl.setValue({ id: 2, text: 'Option 2' }); + comboComponent.formControl.setValue(2); tick(); fixture.detectChanges(); @@ -877,6 +880,9 @@ describe('IgxSimpleCombo', () => { tick(); fixture.detectChanges(); + comboComponent.items = [{ id: '', text: '' }]; + fixture.detectChanges(); + const clearButton = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); expect(clearButton).toBeNull(); @@ -923,7 +929,7 @@ describe('IgxSimpleCombo', () => { const clearButtonAfterUndefined = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); expect(clearButtonAfterUndefined).toBeNull(); })); - it('should show clear icon button when empty object is set with ngModel', fakeAsync(() => { + it('should not show clear icon button when empty object is set with ngModel', fakeAsync(() => { fixture = TestBed.createComponent(IgxSimpleComboNgModelComponent); fixture.detectChanges(); @@ -941,7 +947,7 @@ describe('IgxSimpleCombo', () => { fixture.whenStable().then(() => { fixture.detectChanges(); const clearButtonAfterEmptyObject = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); - expect(clearButtonAfterEmptyObject).not.toBeNull(); + expect(clearButtonAfterEmptyObject).toBeNull(); }); })); it('should properly assign the resource string to the aria-label of the clear button',() => { @@ -1062,6 +1068,7 @@ describe('IgxSimpleCombo', () => { const component = fixture.componentInstance; combo = fixture.componentInstance.combo; component.items = ['One', 'Two', 'Three', 'Four', 'Five']; + fixture.detectChanges(); combo.select('Three'); fixture.detectChanges(); expect(combo.selection).toEqual('Three'); @@ -2842,6 +2849,32 @@ describe('IgxSimpleCombo', () => { expect(combo.value).toEqual(5); expect(input.nativeElement.value).toEqual('Product 5'); })); + it('should not select value that do not exist in the data when setting selection via ngModel', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleComboInvalidValuesComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.simpleCombo; + const component = fixture.componentInstance; + + component.selectedItems = 'SF'; // 'SF' is invalid + fixture.detectChanges(); + tick(); + + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + })); + it('should not select value that do not exist in the data when using the select method programmatically', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleComboInvalidValuesComponent); + fixture.detectChanges(); + combo = fixture.componentInstance.simpleCombo; + + combo.select('SF'); // 'SF' is invalid + fixture.detectChanges(); + + expect(combo.selection).toEqual(undefined); + expect(combo.value).toEqual(undefined); + expect(combo.displayValue).toEqual(''); + })); }); describe('Integration', () => { @@ -3471,3 +3504,33 @@ export class IgxSimpleComboDirtyCheckTestComponent implements OnInit { ]; } } + +@Component({ + template: ` + + `, + standalone: true, + imports: [IgxSimpleComboComponent, FormsModule] +}) +export class SimpleComboInvalidValuesComponent implements OnInit { + @ViewChild(IgxSimpleComboComponent, { read: IgxSimpleComboComponent, static: true }) + public simpleCombo: IgxSimpleComboComponent; + + public cities: any[] = []; + public selectedItems: any[] = []; + + constructor(private cdr: ChangeDetectorRef) { } + + public ngOnInit() { + this.cities = [ + { name: 'New York', id: 'NY' }, + { name: 'Los Angeles', id: 'LA' }, + { name: 'Chicago', id: 'CHI' } + ]; + this.cdr.detectChanges(); + } +} From 6308ca99099ae3237eb27ff7712f7271467ba8e5 Mon Sep 17 00:00:00 2001 From: Georgi Anastasov Date: Mon, 14 Oct 2024 16:23:46 +0300 Subject: [PATCH 13/13] feat(simple-combo): exclude non-matching values from selection --- .../simple-combo/simple-combo.component.ts | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.ts index 539ac855880..19e35041d8e 100644 --- a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.ts @@ -192,10 +192,17 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co * ``` */ public select(item: any): void { - if (item !== undefined) { - const newSelection = this.selectionService.add_items(this.id, item instanceof Array ? item : [item], true); - this.setSelection(newSelection); + if (item === undefined) { + return; + } + + let validItems = Array.isArray(item) ? item : [item]; + if (!this.isRemote && this.data?.length) { + validItems = validItems.filter(i => this.isItemInData(i)); } + + const newSelection = this.selectionService.add_items(this.id, validItems, true); + this.setSelection(newSelection, undefined, true, true); } /** @@ -212,12 +219,8 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co /** @hidden @internal */ public writeValue(value: any): void { - const oldSelection = super.selection; - this.selectionService.select_items(this.id, this.isValid(value) ? [value] : [], true); - this.cdr.markForCheck(); - this._displayValue = this.createDisplayText(super.selection, oldSelection); - this._value = this.valueKey ? super.selection.map(item => item[this.valueKey]) : super.selection; - this.filterValue = this._displayValue?.toString() || ''; + this._value = value !== undefined ? [value] : []; + this.setSelection(new Set(this._value), undefined, false, false); } /** @hidden @internal */ @@ -498,14 +501,20 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co return !!searchValue && value.toString().toLowerCase().includes(searchValue.toLowerCase()); }; - protected setSelection(newSelection: any): void { + protected setSelection(newSelection: any, event?: Event, emit: boolean = true, updateModel: boolean = true): void { const newValueAsArray = newSelection ? Array.from(newSelection) as IgxComboItemComponent[] : []; const oldValueAsArray = Array.from(this.selectionService.get(this.id) || []); - const newItems = this.convertKeysToItems(newValueAsArray); + + let validItems = newValueAsArray; + if (!this.isRemote && this.data && this.data.length > 0) { + validItems = validItems.filter(item => this.isItemInData(item)); + } + + const newItems = this.convertKeysToItems(validItems); const oldItems = this.convertKeysToItems(oldValueAsArray); - const displayText = this.createDisplayText(this.convertKeysToItems(newValueAsArray), oldValueAsArray); + const displayText = this.createDisplayText(newItems, oldValueAsArray); const args: ISimpleComboSelectionChangingEventArgs = { - newValue: newValueAsArray[0], + newValue: validItems[0], oldValue: oldValueAsArray[0], newSelection: newItems[0], oldSelection: oldItems[0], @@ -513,23 +522,29 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co owner: this, cancel: false }; - if (args.newSelection !== args.oldSelection) { + + if (emit && args.newSelection !== args.oldSelection) { this.selectionChanging.emit(args); } + // TODO: refactor below code as it sets the selection and the display text if (!args.cancel) { - let argsSelection = this.isValid(args.newValue) - ? args.newValue + const argsSelection = this.isValid(args.newValue) + ? [args.newValue] : []; - argsSelection = Array.isArray(argsSelection) ? argsSelection : [argsSelection]; + this.selectionService.select_items(this.id, argsSelection, true); this._value = argsSelection; + if (this._updateInput) { this.comboInput.value = this._displayValue = this.searchValue = displayText !== args.displayText ? args.displayText : this.createDisplayText(super.selection, [args.oldValue]); } - this._onChangeCallback(args.newValue); + + if (updateModel) { + this._onChangeCallback(args.newValue); + } this._updateInput = true; } else if (this.isRemote) { this.registerRemoteEntries(newValueAsArray, false);