diff --git a/e2e/issues/issue-749.spec.ts b/e2e/issues/issue-749.spec.ts new file mode 100644 index 0000000000..ace7e5023b --- /dev/null +++ b/e2e/issues/issue-749.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Issue #749: Typeahead object value display fix', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.click('text=Typeahead'); + }); + + test('should display object values correctly in typeahead input', async ({ page }) => { + // Wait for typeahead demo to load + await page.waitForSelector('[placeholder="Locations loaded from API"]'); + + // Look for a typeahead that might have object values + const input = page.locator('[placeholder="Locations loaded from API"]').first(); + + // Check that input doesn't show [object Object] + const inputValue = await input.inputValue(); + expect(inputValue).not.toBe('[object Object]'); + + // If there's an initial value, it should be readable + if (inputValue && inputValue.length > 0) { + expect(inputValue).toMatch(/^[a-zA-Z0-9\s]+$/); // Should be readable text + } + }); + + test('should handle object selection and display correctly', async ({ page }) => { + await page.waitForSelector('[placeholder="Locations loaded from API"]'); + + const input = page.locator('[placeholder="Locations loaded from API"]').first(); + await input.click(); + await input.fill('Cal'); + + // Wait for dropdown to appear + await page.waitForSelector('.dropdown-menu', { state: 'visible' }); + + // Select first item + const firstItem = page.locator('.dropdown-item').first(); + await firstItem.click(); + + // Input should show proper text, not [object Object] + const selectedValue = await input.inputValue(); + expect(selectedValue).toBeTruthy(); + expect(selectedValue).not.toBe('[object Object]'); + expect(selectedValue.length).toBeGreaterThan(0); + }); + + test('should maintain functionality after object value fix', async ({ page }) => { + // Ensure the fix doesn't break existing typeahead functionality + await page.waitForSelector('[placeholder="Locations loaded from API"]'); + + const input = page.locator('[placeholder="Locations loaded from API"]').first(); + + // Test typing and filtering + await input.click(); + await input.fill('Ala'); + + // Should show filtered results + await page.waitForSelector('.dropdown-menu', { state: 'visible' }); + + const items = page.locator('.dropdown-item'); + const itemCount = await items.count(); + expect(itemCount).toBeGreaterThan(0); + + // Items should contain the typed text + const firstItemText = await items.first().textContent(); + expect(firstItemText).toContain('Ala'); + }); + + test('should handle keyboard navigation with object values', async ({ page }) => { + await page.waitForSelector('[placeholder="Locations loaded from API"]'); + + const input = page.locator('[placeholder="Locations loaded from API"]').first(); + await input.click(); + await input.fill('A'); + + // Wait for dropdown + await page.waitForSelector('.dropdown-menu', { state: 'visible' }); + + // Navigate with arrow keys + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + // Should select properly without showing [object Object] + const selectedValue = await input.inputValue(); + expect(selectedValue).toBeTruthy(); + expect(selectedValue).not.toBe('[object Object]'); + expect(selectedValue).not.toBe('[object HTMLElement]'); + }); + + test('should handle edge cases for object value display', async ({ page }) => { + // Test various scenarios that might cause [object Object] display + await page.waitForSelector('[placeholder="Locations loaded from API"]'); + + const input = page.locator('[placeholder="Locations loaded from API"]').first(); + + // Test clearing and re-entering values + await input.click(); + await input.fill(''); + await input.fill('Test'); + + // Clear again + await input.fill(''); + + // Should not show [object Object] in any state + const inputValue = await input.inputValue(); + expect(inputValue).not.toBe('[object Object]'); + }); + + test('should work with different typeahead configurations', async ({ page }) => { + // Test that the fix works across different typeahead setups + await page.waitForSelector('input[typeahead]').catch(() => { + // Fallback if specific selector doesn't exist + return page.waitForSelector('input'); + }); + + // Find any typeahead inputs on the page + const typeaheadInputs = page.locator('input[typeahead], input[placeholder*="type"], input[placeholder*="search"]'); + const count = await typeaheadInputs.count(); + + if (count > 0) { + for (let i = 0; i < Math.min(count, 3); i++) { + const input = typeaheadInputs.nth(i); + const inputValue = await input.inputValue(); + + // None should show [object Object] + expect(inputValue).not.toBe('[object Object]'); + + // If visible and enabled, test basic functionality + if (await input.isVisible() && await input.isEnabled()) { + await input.click(); + await input.fill('test'); + await input.fill(''); + + // Still shouldn't show [object Object] + const clearedValue = await input.inputValue(); + expect(clearedValue).not.toBe('[object Object]'); + } + } + } + }); +}); \ No newline at end of file diff --git a/src/typeahead/testing/typeahead-object-value.spec.ts b/src/typeahead/testing/typeahead-object-value.spec.ts new file mode 100644 index 0000000000..77641318cd --- /dev/null +++ b/src/typeahead/testing/typeahead-object-value.spec.ts @@ -0,0 +1,180 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TypeaheadDirective } from '../typeahead.directive'; +import { TypeaheadModule } from '../typeahead.module'; + +@Component({ + template: ` + + ` +}) +class TestTypeaheadObjectValueComponent { + @ViewChild(TypeaheadDirective, { static: true }) typeahead!: TypeaheadDirective; + + selectedState = { name: 'California', code: 'CA' }; // Initial object value + + states = [ + { name: 'Alabama', code: 'AL' }, + { name: 'Alaska', code: 'AK' }, + { name: 'Arizona', code: 'AZ' }, + { name: 'Arkansas', code: 'AR' }, + { name: 'California', code: 'CA' } + ]; +} + +@Component({ + template: ` + + ` +}) +class TestTypeaheadNoOptionFieldComponent { + @ViewChild(TypeaheadDirective, { static: true }) typeahead!: TypeaheadDirective; + + selectedState = { name: 'California', code: 'CA' }; // Initial object value with no typeaheadOptionField + + states = [ + { name: 'Alabama', code: 'AL' }, + { name: 'Alaska', code: 'AK' }, + { name: 'California', code: 'CA' } + ]; +} + +describe('TypeaheadDirective - Object Value Display Issue #749', () => { + let component: TestTypeaheadObjectValueComponent; + let fixture: ComponentFixture; + let noFieldComponent: TestTypeaheadNoOptionFieldComponent; + let noFieldFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TestTypeaheadObjectValueComponent, TestTypeaheadNoOptionFieldComponent], + imports: [FormsModule, TypeaheadModule.forRoot()] + }).compileComponents(); + }); + + describe('With typeaheadOptionField specified', () => { + beforeEach(() => { + fixture = TestBed.createComponent(TestTypeaheadObjectValueComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should display object property value when typeaheadOptionField is set', () => { + const input = fixture.nativeElement.querySelector('input'); + + // Input should display the name property, not "[object Object]" + expect(input.value).toBe('California'); + expect(input.value).not.toBe('[object Object]'); + }); + + it('should maintain object value in ngModel', () => { + expect(component.selectedState).toEqual({ name: 'California', code: 'CA' }); + expect(typeof component.selectedState).toBe('object'); + }); + + it('should handle selection and update display correctly', () => { + const input = fixture.nativeElement.querySelector('input'); + + // Clear and type new value + input.value = 'Alab'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + // Should be able to select from dropdown + expect(component.typeahead).toBeTruthy(); + }); + }); + + describe('Without typeaheadOptionField (auto-detect common fields)', () => { + beforeEach(() => { + noFieldFixture = TestBed.createComponent(TestTypeaheadNoOptionFieldComponent); + noFieldComponent = noFieldFixture.componentInstance; + noFieldFixture.detectChanges(); + }); + + it('should auto-detect name field and display correctly', () => { + const input = noFieldFixture.nativeElement.querySelector('input'); + + // After fix, should detect 'name' field automatically + expect(input.value).toBe('California'); + expect(input.value).not.toBe('[object Object]'); + }); + + it('should have object value in ngModel', () => { + expect(noFieldComponent.selectedState).toEqual({ name: 'California', code: 'CA' }); + expect(typeof noFieldComponent.selectedState).toBe('object'); + }); + }); + + describe('Object value handling utilities', () => { + it('should handle getValueFromObject correctly', () => { + const { getValueFromObject } = require('../typeahead-utils'); + + const testObject = { name: 'California', code: 'CA' }; + + // With option field specified + expect(getValueFromObject(testObject, 'name')).toBe('California'); + expect(getValueFromObject(testObject, 'code')).toBe('CA'); + + // Without option field (auto-detect 'name') + expect(getValueFromObject(testObject, undefined)).toBe('California'); + }); + + it('should auto-detect common display fields', () => { + const { getValueFromObject } = require('../typeahead-utils'); + + // Test different common field names + expect(getValueFromObject({ name: 'Test' }, undefined)).toBe('Test'); + expect(getValueFromObject({ label: 'Test Label' }, undefined)).toBe('Test Label'); + expect(getValueFromObject({ title: 'Test Title' }, undefined)).toBe('Test Title'); + expect(getValueFromObject({ text: 'Test Text' }, undefined)).toBe('Test Text'); + expect(getValueFromObject({ value: 'Test Value' }, undefined)).toBe('Test Value'); + expect(getValueFromObject({ display: 'Test Display' }, undefined)).toBe('Test Display'); + + // Should prioritize 'name' over other fields + expect(getValueFromObject({ name: 'Name', label: 'Label' }, undefined)).toBe('Name'); + }); + + it('should fallback to [object Object] for objects without common fields', () => { + const { getValueFromObject } = require('../typeahead-utils'); + + const objectWithoutCommonFields = { id: 1, customField: 'value' }; + expect(getValueFromObject(objectWithoutCommonFields, undefined)).toBe('[object Object]'); + }); + + it('should handle nested properties', () => { + const { getValueFromObject } = require('../typeahead-utils'); + + const nestedObject = { + location: { + state: { name: 'California' }, + country: 'USA' + } + }; + + expect(getValueFromObject(nestedObject, 'location.state.name')).toBe('California'); + expect(getValueFromObject(nestedObject, 'location.country')).toBe('USA'); + }); + + it('should handle method calls', () => { + const { getValueFromObject } = require('../typeahead-utils'); + + const objectWithMethod = { + name: 'California', + getName: function() { return this.name; } + }; + + expect(getValueFromObject(objectWithMethod, 'getName()')).toBe('California'); + }); + }); +}); \ No newline at end of file diff --git a/src/typeahead/typeahead-utils.ts b/src/typeahead/typeahead-utils.ts index 851f9243a7..ae7292dddb 100644 --- a/src/typeahead/typeahead-utils.ts +++ b/src/typeahead/typeahead-utils.ts @@ -61,7 +61,19 @@ function tokenizeWordsAndPhrases(str: string, wordRegexDelimiters: string, phras // eslint-disable-next-line export function getValueFromObject(object: string | Record, option?: string): string { - if (!option || typeof object !== 'object') { + if (typeof object !== 'object') { + return object.toString(); + } + + // If no option specified, try common display field names + if (!option) { + const commonFields = ['name', 'label', 'title', 'text', 'value', 'display']; + for (const field of commonFields) { + if (field in object && object[field] != null) { + return object[field].toString(); + } + } + // Fallback to object.toString() which will show [object Object] return object.toString(); } diff --git a/src/typeahead/typeahead.directive.ts b/src/typeahead/typeahead.directive.ts index 4a074a05c2..f13c56efba 100644 --- a/src/typeahead/typeahead.directive.ts +++ b/src/typeahead/typeahead.directive.ts @@ -236,6 +236,9 @@ export class TypeaheadDirective implements OnInit, OnDestroy { } this.checkDelimitersConflict(); + + // Handle initial object value display + setTimeout(() => this.handleInitialObjectValue()); } @HostListener('input', ['$event']) @@ -446,6 +449,20 @@ export class TypeaheadDirective implements OnInit, OnDestroy { this._typeahead.dispose(); } + protected handleInitialObjectValue(): void { + const currentValue = this.ngControl.control?.value; + + // Check if current value is an object and we have an option field specified + if (currentValue && typeof currentValue === 'object' && this.typeaheadOptionField) { + const displayValue = getValueFromObject(currentValue, this.typeaheadOptionField); + + // Update the input element's display value if it's different + if (this.element.nativeElement.value !== displayValue) { + this.renderer.setProperty(this.element.nativeElement, 'value', displayValue); + } + } + } + protected asyncActions(): void { this._subscriptions.push( this.keyUpEventEmitter