mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
e6378eb330
The search dialog picked its empty state ("Start typing" vs "No results
found") with `!resultsResource.hasValue()`, which during a re-query looks
identical to a freshly opened dialog since the resource resets its value and
reports `loading`. This flipped the message and mascot back to "Start typing"
mid-search, causing a question -> magnifying-glass -> question flicker on every
keystroke. Add an `emptyState` signal that holds the previous state while a
search is settling, so the dialog only changes once results resolve.
211 lines
6.6 KiB
TypeScript
211 lines
6.6 KiB
TypeScript
/*!
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {ApplicationRef} from '@angular/core';
|
|
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
|
import {By} from '@angular/platform-browser';
|
|
import {Router, provideRouter} from '@angular/router';
|
|
|
|
import {SearchDialog} from './search-dialog.component';
|
|
import {ENVIRONMENT, WINDOW} from '../../providers';
|
|
import {ALGOLIA_CLIENT, Search} from '../../services';
|
|
import {FakeEventTarget, timeout, useAutoTick} from '../../testing/index';
|
|
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
|
|
import {SearchResult} from '../../interfaces';
|
|
|
|
describe('SearchDialog', () => {
|
|
let fixture: ComponentFixture<SearchDialog>;
|
|
|
|
const searchResults = jasmine.createSpy();
|
|
|
|
const fakeWindow = new FakeEventTarget();
|
|
|
|
let search: Search;
|
|
|
|
useAutoTick();
|
|
|
|
beforeEach(async () => {
|
|
searchResults.and.returnValue(Promise.resolve({results: [{hits: []}]}));
|
|
|
|
TestBed.configureTestingModule({
|
|
imports: [SearchDialog],
|
|
providers: [
|
|
provideRouter([]),
|
|
{provide: ENVIRONMENT, useValue: {algolia: {index: 'fakeIndex'}}},
|
|
{provide: ALGOLIA_CLIENT, useValue: {search: searchResults}},
|
|
{provide: WINDOW, useValue: fakeWindow},
|
|
],
|
|
});
|
|
|
|
fixture = TestBed.createComponent(SearchDialog);
|
|
search = TestBed.inject(Search);
|
|
await fixture.whenStable();
|
|
});
|
|
|
|
it('should navigate to active item when user pressed Enter', async () => {
|
|
const router = TestBed.inject(Router);
|
|
const navigateByUrlSpy = spyOn(router, 'navigateByUrl');
|
|
|
|
search.searchQuery.set('fakeQuery');
|
|
searchResults.and.returnValue(Promise.resolve({results: [{hits: fakeSearchResults}]}));
|
|
|
|
// Fire the request
|
|
TestBed.inject(ApplicationRef).tick();
|
|
|
|
// The delay from debounced (200ms)
|
|
await timeout(300);
|
|
|
|
// Wait for the resource to resolve
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
fakeWindow.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
code: 'Enter',
|
|
key: 'Enter',
|
|
charCode: 13,
|
|
keyCode: 13,
|
|
view: window,
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
|
|
expect(navigateByUrlSpy).toHaveBeenCalledOnceWith('fakeUrl1#h1');
|
|
});
|
|
|
|
it('should always display algolia logo', () => {
|
|
const algoliaIcon = fixture.debugElement.query(By.directive(AlgoliaIcon));
|
|
|
|
expect(algoliaIcon).toBeTruthy();
|
|
});
|
|
|
|
it('should display `No results found` message when there are no results for provided query', async () => {
|
|
search.searchQuery.set('fakeQuery');
|
|
searchResults.and.returnValue(Promise.resolve({results: [{hits: []}]}));
|
|
|
|
// Fire the request
|
|
TestBed.inject(ApplicationRef).tick();
|
|
|
|
// The delay from debounced (200ms)
|
|
await timeout(300);
|
|
|
|
// Wait for the resource to resolve
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const noResultsContainer = fixture.debugElement.query(
|
|
By.css('.docs-search-results__no-results'),
|
|
);
|
|
|
|
expect(noResultsContainer).toBeTruthy();
|
|
});
|
|
|
|
it('should display `Start typing to see results` message when there are no provided query', () => {
|
|
searchResults.and.returnValue(Promise.resolve(undefined));
|
|
|
|
const startTypingContainer = fixture.debugElement.query(
|
|
By.css('.docs-search-results__start-typing'),
|
|
);
|
|
|
|
expect(startTypingContainer).toBeTruthy();
|
|
});
|
|
|
|
it('should keep the `No results found` message while re-querying instead of flickering back to `Start typing`', async () => {
|
|
const appRef = TestBed.inject(ApplicationRef);
|
|
|
|
// Settle on an empty result set so `No results found` is shown.
|
|
search.searchQuery.set('foobarbaz');
|
|
searchResults.and.returnValue(Promise.resolve({results: [{hits: []}]}));
|
|
appRef.tick();
|
|
await timeout(300);
|
|
await appRef.whenStable();
|
|
|
|
expect(fixture.debugElement.query(By.css('.docs-search-results__no-results'))).toBeTruthy();
|
|
|
|
// Edit the query so a new search goes in flight and stays pending.
|
|
let resolveReload!: (value: unknown) => void;
|
|
searchResults.and.returnValue(new Promise((resolve) => (resolveReload = resolve)));
|
|
search.searchQuery.set('foobarba');
|
|
appRef.tick();
|
|
|
|
// The delay from debounced (200ms), after which the pending request is loading.
|
|
await timeout(300);
|
|
|
|
// While the re-query is loading it must not revert to the `Start typing` state.
|
|
expect(fixture.debugElement.query(By.css('.docs-search-results__start-typing'))).toBeNull();
|
|
expect(fixture.debugElement.query(By.css('.docs-search-results__no-results'))).toBeTruthy();
|
|
|
|
resolveReload({results: [{hits: []}]});
|
|
await appRef.whenStable();
|
|
});
|
|
|
|
it('should display list of the search results when results exist', async () => {
|
|
search.searchQuery.set('fakeQuery');
|
|
searchResults.and.returnValue(Promise.resolve({results: [{hits: fakeSearchResults}]}));
|
|
|
|
// Fire the request
|
|
TestBed.inject(ApplicationRef).tick();
|
|
|
|
// The delay from debounced (200ms)
|
|
await timeout(300);
|
|
|
|
// Wait for the resource to resolve
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const resultListContainer = fixture.debugElement.query(By.css('ul.docs-search-results'));
|
|
const resultItems = fixture.debugElement.queryAll(By.css('ul.docs-search-results li a'));
|
|
|
|
expect(resultListContainer).toBeTruthy();
|
|
expect(resultItems.length).toBe(2);
|
|
expect(resultItems[0].nativeElement.href).toBe(`${window.origin}/fakeUrl1#h1`);
|
|
expect(resultItems[1].nativeElement.href).toBe(`${window.origin}/fakeUrl2#h1`);
|
|
});
|
|
|
|
it('should close search dialog when user clicked outside `.docs-search-container`', () => {
|
|
const dialogContainer = fixture.debugElement.query(By.css('dialog'));
|
|
const closeSearchDialogSpy = spyOn(fixture.componentInstance, 'closeSearchDialog');
|
|
|
|
dialogContainer.nativeElement.click();
|
|
|
|
expect(closeSearchDialogSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
const fakeSearchResults = [
|
|
{
|
|
'url': 'https://angular.dev/fakeUrl1#h1',
|
|
'hierarchy': {
|
|
'lvl0': 'FakeLvl0',
|
|
'lvl1': 'FakeLvl1',
|
|
'lvl2': 'FakeLvl2',
|
|
'lvl3': null,
|
|
'lvl4': null,
|
|
'lvl5': null,
|
|
'lvl6': null,
|
|
},
|
|
'objectID': 'fakeObjectId1',
|
|
_snippetResult: {},
|
|
type: '',
|
|
content: null,
|
|
},
|
|
{
|
|
'url': 'https://angular.dev/fakeUrl2#h1',
|
|
'hierarchy': {
|
|
'lvl0': 'FakeLvl0',
|
|
'lvl1': 'FakeLvl1',
|
|
'lvl2': 'FakeLvl2',
|
|
'lvl3': null,
|
|
'lvl4': null,
|
|
'lvl5': null,
|
|
'lvl6': null,
|
|
},
|
|
'objectID': 'fakeObjectId2',
|
|
type: '',
|
|
content: null,
|
|
_snippetResult: {},
|
|
},
|
|
] satisfies SearchResult[];
|