Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(combo): filter out non-existent items from selection - master #14772

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 67 additions & 5 deletions projects/igniteui-angular/src/lib/combo/combo.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -114,7 +115,7 @@ describe('igxCombo', () => {
spyOnProperty(combo, 'isRemote').and.returnValue(false);
combo.writeValue(['test']);
expect(mockNgControl.registerOnChangeCb).not.toHaveBeenCalled();
expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, ['test'], true);
expect(mockSelection.select_items).toHaveBeenCalledWith(combo.id, [], true);
expect(combo.displayValue).toEqual('test');
expect(combo.value).toEqual(['test']);

Expand All @@ -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
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -2604,6 +2607,35 @@ 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');
}));
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;
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');
}));
});
describe('Grouping tests: ', () => {
beforeEach(() => {
Expand Down Expand Up @@ -3335,7 +3367,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);

Expand Down Expand Up @@ -4087,3 +4119,33 @@ export class ComboWithIdComponent {
];
}
}

@Component({
template: `
<igx-combo
[(ngModel)]="selectedItems"
[data]="cities"
[valueKey]="'id'"
[displayKey]="'name'">
</igx-combo>`,
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();
}
}
62 changes: 52 additions & 10 deletions projects/igniteui-angular/src/lib/combo/combo.component.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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';

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';
Expand Down Expand Up @@ -124,7 +124,7 @@ const diffInSets = (set1: Set<any>, set2: Set<any>): 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
Expand Down Expand Up @@ -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 */
Expand All @@ -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
*/
Expand Down Expand Up @@ -309,8 +317,16 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie
* ```
*/
public select(newItems: Array<any>, 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);
}
}
Expand Down Expand Up @@ -469,4 +485,30 @@ 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;
}

private isItemInData(item: any): boolean {
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;
});
}
}
41 changes: 41 additions & 0 deletions projects/igniteui-angular/src/lib/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,47 @@
}
});

/**
* 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.
Expand Down Expand Up @@ -242,9 +283,9 @@
export class PlatformUtil {
public isBrowser: boolean = isPlatformBrowser(this.platformId);
public isIOS = this.isBrowser && /iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window);
public isSafari = this.isBrowser && /Safari[\/\s](\d+\.\d+)/.test(navigator.userAgent);

Check warning on line 286 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (18.x)

Unnecessary escape character: \/

Check warning on line 286 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (20.x)

Unnecessary escape character: \/
public isFirefox = this.isBrowser && /Firefox[\/\s](\d+\.\d+)/.test(navigator.userAgent);

Check warning on line 287 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (18.x)

Unnecessary escape character: \/

Check warning on line 287 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (20.x)

Unnecessary escape character: \/
public isEdge = this.isBrowser && /Edge[\/\s](\d+\.\d+)/.test(navigator.userAgent);

Check warning on line 288 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (18.x)

Unnecessary escape character: \/

Check warning on line 288 in projects/igniteui-angular/src/lib/core/utils.ts

View workflow job for this annotation

GitHub Actions / run-tests (20.x)

Unnecessary escape character: \/
public isChromium = this.isBrowser && (/Chrom|e?ium/g.test(navigator.userAgent) ||
/Google Inc/g.test(navigator.vendor)) && !/Edge/g.test(navigator.userAgent);
public browserVersion = this.isBrowser ? parseFloat(navigator.userAgent.match(/Version\/([\d.]+)/)?.at(1)) : 0;
Expand Down
Loading