Skip to content

fix(typeahead): resolve [object Object] display issue with initial object values #6750

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

Open
wants to merge 2 commits into
base: development
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
142 changes: 142 additions & 0 deletions e2e/issues/issue-749.spec.ts
Original file line number Diff line number Diff line change
@@ -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]');
}
}
}
});
});
180 changes: 180 additions & 0 deletions src/typeahead/testing/typeahead-object-value.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `
<input
[(ngModel)]="selectedState"
[typeahead]="states"
[typeaheadOptionField]="'name'"
class="form-control"
data-test="object-value-input">
`
})
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: `
<input
[(ngModel)]="selectedState"
[typeahead]="states"
class="form-control"
data-test="no-option-field-input">
`
})
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<TestTypeaheadObjectValueComponent>;
let noFieldComponent: TestTypeaheadNoOptionFieldComponent;
let noFieldFixture: ComponentFixture<TestTypeaheadNoOptionFieldComponent>;

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');
});
});
});
14 changes: 13 additions & 1 deletion src/typeahead/typeahead-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,19 @@ function tokenizeWordsAndPhrases(str: string, wordRegexDelimiters: string, phras

// eslint-disable-next-line
export function getValueFromObject(object: string | Record<string | number, any>, 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();
}

Expand Down
17 changes: 17 additions & 0 deletions src/typeahead/typeahead.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ export class TypeaheadDirective implements OnInit, OnDestroy {
}

this.checkDelimitersConflict();

// Handle initial object value display
setTimeout(() => this.handleInitialObjectValue());
}

@HostListener('input', ['$event'])
Expand Down Expand Up @@ -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
Expand Down
Loading