From 2af330df61f96150823704e5591d7dbb683c74b7 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Fri, 14 Mar 2025 17:26:40 +0100 Subject: [PATCH] docs(docs-infra): Search results as HTML (#60394) fixes #60384 PR Close #60394 --- .../search-dialog.component.html | 55 ++-- .../search-dialog.component.scss | 17 +- .../search-dialog.component.spec.ts | 77 +++-- .../search-dialog/search-dialog.component.ts | 50 +--- .../search-item/search-item.directive.ts | 12 +- adev/shared-docs/interfaces/search-results.ts | 11 + adev/shared-docs/services/search.service.ts | 270 +++++++++++++----- adev/src/app/app.config.ts | 2 + 8 files changed, 286 insertions(+), 208 deletions(-) diff --git a/adev/shared-docs/components/search-dialog/search-dialog.component.html b/adev/shared-docs/components/search-dialog/search-dialog.component.html index 4ed336bf037..51598f60d6d 100644 --- a/adev/shared-docs/components/search-dialog/search-dialog.component.html +++ b/adev/shared-docs/components/search-dialog/search-dialog.component.html @@ -3,15 +3,14 @@ - @if (searchResults() && searchResults()!.length > 0) { + @if (searchResults.hasValue() && searchResults.value().length > 0) { } @else {
- @if (searchResults() === undefined) { + @if (!searchResults.hasValue()) {
Start typing to see results
- } @else if (searchResults()?.length === 0) { + } @else if (searchResults.value().length === 0) {
No results found
@@ -70,21 +59,13 @@
Search by - +
- - - @let parts = splitMarkedText(snippet); - @for (part of parts; track $index) { - @if (part.highlight) { - {{part.text}} - } @else { - {{part.text}} - } - } - diff --git a/adev/shared-docs/components/search-dialog/search-dialog.component.scss b/adev/shared-docs/components/search-dialog/search-dialog.component.scss index 995d880ad1d..8aa46138e4e 100644 --- a/adev/shared-docs/components/search-dialog/search-dialog.component.scss +++ b/adev/shared-docs/components/search-dialog/search-dialog.component.scss @@ -51,12 +51,17 @@ dialog { padding-inline-end: 1rem; padding-block: 0.25rem; - mark { - background: #e62600; - background: var(--red-to-orange-horizontal-gradient); - background-clip: text; - -webkit-background-clip: text; - color: transparent; + /** + * This rule needs ng-deep to be applied to elements that are created via a [innerHTML] binding + */ + ::ng-deep { + mark { + background: #e62600; + background: var(--red-to-orange-horizontal-gradient); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + } } a { diff --git a/adev/shared-docs/components/search-dialog/search-dialog.component.spec.ts b/adev/shared-docs/components/search-dialog/search-dialog.component.spec.ts index b06863c6cad..43d0ac939fc 100644 --- a/adev/shared-docs/components/search-dialog/search-dialog.component.spec.ts +++ b/adev/shared-docs/components/search-dialog/search-dialog.component.spec.ts @@ -9,54 +9,59 @@ import {ComponentFixture, TestBed} from '@angular/core/testing'; import {SearchDialog} from './search-dialog.component'; -import {WINDOW} from '../../providers'; -import {Search} from '../../services'; +import {ENVIRONMENT, WINDOW} from '../../providers'; +import {ALGOLIA_CLIENT, Search} from '../../services'; import {FakeEventTarget} from '../../testing/index'; import {By} from '@angular/platform-browser'; import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component'; import {RouterTestingModule} from '@angular/router/testing'; import {Router} from '@angular/router'; -import {provideExperimentalZonelessChangeDetection} from '@angular/core'; +import { + ApplicationRef, + provideExperimentalZonelessChangeDetection, + ResourceStatus, +} from '@angular/core'; import {SearchResult} from '../../interfaces'; describe('SearchDialog', () => { let fixture: ComponentFixture; - const fakeSearch = { - searchQuery: jasmine.createSpy(), - searchResults: jasmine.createSpy(), - }; + const searchResults = jasmine.createSpy(); + const fakeWindow = new FakeEventTarget(); - beforeEach(async () => { - fakeSearch.searchResults.and.returnValue([]); - fakeSearch.searchQuery.and.returnValue(''); + let search: Search; - await TestBed.configureTestingModule({ + beforeEach(async () => { + searchResults.and.returnValue([]); + + TestBed.configureTestingModule({ imports: [SearchDialog, RouterTestingModule], providers: [ provideExperimentalZonelessChangeDetection(), - { - provide: Search, - useValue: fakeSearch, - }, - { - provide: WINDOW, - useValue: fakeWindow, - }, + {provide: ENVIRONMENT, useValue: {algolia: {index: 'fakeIndex'}}}, + {provide: ALGOLIA_CLIENT, useValue: {search: searchResults}}, + {provide: WINDOW, useValue: fakeWindow}, ], - }).compileComponents(); + }); fixture = TestBed.createComponent(SearchDialog); fixture.detectChanges(); + search = TestBed.inject(Search); }); - it('should navigate to active item when user pressed Enter', () => { + it('should navigate to active item when user pressed Enter', async () => { const router = TestBed.inject(Router); const navigateByUrlSpy = spyOn(router, 'navigateByUrl'); - fakeSearch.searchResults.and.returnValue(fakeSearchResults); - fixture.detectChanges(); + search.searchQuery.set('fakeQuery'); + searchResults.and.returnValue(Promise.resolve({results: [{hits: fakeSearchResults}]})); + + // Fire the request + TestBed.inject(ApplicationRef).tick(); + + // Wait for the resource to resolve + await TestBed.inject(ApplicationRef).whenStable(); fakeWindow.dispatchEvent( new KeyboardEvent('keydown', { @@ -78,9 +83,15 @@ describe('SearchDialog', () => { expect(algoliaIcon).toBeTruthy(); }); - it('should display `No results found` message when there are no results for provided query', () => { - fakeSearch.searchResults.and.returnValue([]); - fixture.detectChanges(); + 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(); + + // Wait for the resource to resolve + await TestBed.inject(ApplicationRef).whenStable(); const noResultsContainer = fixture.debugElement.query( By.css('.docs-search-results__no-results'), @@ -90,7 +101,7 @@ describe('SearchDialog', () => { }); it('should display `Start typing to see results` message when there are no provided query', () => { - fakeSearch.searchResults.and.returnValue(undefined); + searchResults.and.returnValue(undefined); fixture.detectChanges(); const startTypingContainer = fixture.debugElement.query( @@ -100,9 +111,15 @@ describe('SearchDialog', () => { expect(startTypingContainer).toBeTruthy(); }); - it('should display list of the search results when results exist', () => { - fakeSearch.searchResults.and.returnValue(fakeSearchResults); - fixture.detectChanges(); + 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(); + + // 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')); diff --git a/adev/shared-docs/components/search-dialog/search-dialog.component.ts b/adev/shared-docs/components/search-dialog/search-dialog.component.ts index a1aea27fe71..2de59b93aa9 100644 --- a/adev/shared-docs/components/search-dialog/search-dialog.component.ts +++ b/adev/shared-docs/components/search-dialog/search-dialog.component.ts @@ -12,7 +12,6 @@ import { ElementRef, Injector, OnDestroy, - Signal, afterNextRender, effect, inject, @@ -20,7 +19,6 @@ import { viewChild, viewChildren, } from '@angular/core'; -import {NgTemplateOutlet} from '@angular/common'; import {WINDOW} from '../../providers/index'; import {ClickOutside} from '../../directives/index'; @@ -32,10 +30,9 @@ import {ActiveDescendantKeyManager} from '@angular/cdk/a11y'; import {SearchItem} from '../../directives/search-item/search-item.directive'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {Router, RouterLink} from '@angular/router'; -import {filter, fromEvent} from 'rxjs'; +import {fromEvent} from 'rxjs'; import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component'; import {RelativeLink} from '../../pipes/relative-link.pipe'; -import {SearchResult, SnippetResult} from '../../interfaces'; @Component({ selector: 'docs-search-dialog', @@ -48,7 +45,6 @@ import {SearchResult, SnippetResult} from '../../interfaces'; AlgoliaIcon, RelativeLink, RouterLink, - NgTemplateOutlet, ], templateUrl: './search-dialog.component.html', styleUrls: ['./search-dialog.component.scss'], @@ -106,46 +102,6 @@ export class SearchDialog implements OnDestroy { }); } - splitMarkedText(snippet: string): Array<{highlight: boolean; text: string}> { - const parts: Array<{highlight: boolean; text: string}> = []; - while (snippet.indexOf('<ɵ>') !== -1) { - const beforeMatch = snippet.substring(0, snippet.indexOf('<ɵ>')); - const match = snippet.substring(snippet.indexOf('<ɵ>') + 3, snippet.indexOf('')); - parts.push({highlight: false, text: beforeMatch}); - parts.push({highlight: true, text: match}); - snippet = snippet.substring(snippet.indexOf('') + 4); - } - parts.push({highlight: false, text: snippet}); - return parts; - } - - getBestSnippetForMatch(result: SearchResult): string { - // if there is content, return it - if (result._snippetResult.content !== undefined) { - return result._snippetResult.content.value; - } - - const hierarchy = result._snippetResult.hierarchy; - if (hierarchy === undefined) { - return ''; - } - function matched(snippet: SnippetResult | undefined) { - return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none'; - } - // return the most specific subheader match - if (matched(hierarchy.lvl4)) { - return hierarchy.lvl4!.value; - } - if (matched(hierarchy.lvl3)) { - return hierarchy.lvl3!.value; - } - if (matched(hierarchy.lvl2)) { - return hierarchy.lvl2!.value; - } - // if no subheader matched the query, fall back to just returning the most specific one - return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? ''; - } - ngOnDestroy(): void { this.keyManager.destroy(); } @@ -155,10 +111,6 @@ export class SearchDialog implements OnDestroy { this.onClose.emit(); } - updateSearchQuery(query: string) { - this.search.updateSearchQuery(query); - } - private navigateToTheActiveItem(): void { const activeItemLink: string | undefined = this.keyManager.activeItem?.item?.url; diff --git a/adev/shared-docs/directives/search-item/search-item.directive.ts b/adev/shared-docs/directives/search-item/search-item.directive.ts index de4339bb79b..7e8f185baa4 100644 --- a/adev/shared-docs/directives/search-item/search-item.directive.ts +++ b/adev/shared-docs/directives/search-item/search-item.directive.ts @@ -8,7 +8,7 @@ import {Directive, ElementRef, Input, inject, signal} from '@angular/core'; import {Highlightable} from '@angular/cdk/a11y'; -import {SearchResult} from '../../interfaces/search-results'; +import {SearchResultItem} from '../../interfaces'; @Directive({ selector: '[docsSearchItem]', @@ -17,7 +17,7 @@ import {SearchResult} from '../../interfaces/search-results'; }, }) export class SearchItem implements Highlightable { - @Input() item?: SearchResult; + @Input() item?: SearchResultItem; @Input() disabled = false; private readonly elementRef = inject(ElementRef); @@ -36,14 +36,6 @@ export class SearchItem implements Highlightable { this._isActive.set(false); } - getLabel(): string { - if (!this.item?.hierarchy) { - return ''; - } - const {hierarchy} = this.item; - return `${hierarchy.lvl0}${hierarchy.lvl1}${hierarchy.lvl2}`; - } - scrollIntoView(): void { this.elementRef?.nativeElement.scrollIntoView({block: 'nearest'}); } diff --git a/adev/shared-docs/interfaces/search-results.ts b/adev/shared-docs/interfaces/search-results.ts index 113f010b123..53228343608 100644 --- a/adev/shared-docs/interfaces/search-results.ts +++ b/adev/shared-docs/interfaces/search-results.ts @@ -54,3 +54,14 @@ export interface Hierarchy { lvl5: string | null; lvl6: string | null; } + +/** Parsed & Structured search results */ +export interface SearchResultItem { + type: 'doc' | 'code'; + labelHtml: string | null; + subLabelHtml: string | null; + url: string; + + id: string; + category: string | null; +} diff --git a/adev/shared-docs/services/search.service.ts b/adev/shared-docs/services/search.service.ts index 4fa61f1279e..c4be8addb48 100644 --- a/adev/shared-docs/services/search.service.ts +++ b/adev/shared-docs/services/search.service.ts @@ -6,95 +6,105 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Injectable, afterNextRender, inject, signal} from '@angular/core'; -import {ENVIRONMENT} from '../providers/index'; -import {SearchResult} from '../interfaces/index'; -import {toObservable} from '@angular/core/rxjs-interop'; -import {debounceTime, filter, from, of, switchMap} from 'rxjs'; -import {liteClient as algoliasearch} from 'algoliasearch/lite'; +import { + DestroyRef, + Injectable, + InjectionToken, + Provider, + ResourceRef, + inject, + resource, + signal, +} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import {NavigationEnd, Router} from '@angular/router'; +import {ENVIRONMENT} from '../providers/index'; +import type {Environment, SearchResult, SearchResultItem, SnippetResult} from '../interfaces/index'; +import {filter} from 'rxjs'; +import { + liteClient as algoliasearch, + SearchResponses, + SearchResult as AlgoliaSearchResult, + Hit, +} from 'algoliasearch/lite'; export const SEARCH_DELAY = 200; // Maximum number of facet values to return for each facet during a regular search. export const MAX_VALUE_PER_FACET = 5; +export const ALGOLIA_CLIENT = new InjectionToken>( + 'Search service', +); + +export const provideAlgoliaSearchClient = (config: Environment): Provider => { + return { + provide: ALGOLIA_CLIENT, + useFactory: () => algoliasearch(config.algolia.appId, config.algolia.apiKey), + }; +}; + @Injectable({ providedIn: 'root', }) export class Search { - private readonly _searchQuery = signal(''); - private readonly _searchResults = signal(undefined); + readonly searchQuery = signal(''); + private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly config = inject(ENVIRONMENT); - private readonly client = algoliasearch(this.config.algolia.appId, this.config.algolia.apiKey); + private readonly client = inject(ALGOLIA_CLIENT); - searchQuery = this._searchQuery.asReadonly(); - searchResults = this._searchResults.asReadonly(); + searchResults = resource({ + request: () => this.searchQuery() || undefined, // coerces empty string to undefined + loader: async ({request: query, abortSignal}) => { + // Until we have a better alternative we debounce by awaiting for a short delay. + await wait(SEARCH_DELAY, abortSignal); - searchResults$ = toObservable(this.searchQuery).pipe( - debounceTime(SEARCH_DELAY), - switchMap((query) => { - return !!query - ? from( - this.client.search([ - { - indexName: this.config.algolia.indexName, - params: { - query: query, - maxValuesPerFacet: MAX_VALUE_PER_FACET, - attributesToRetrieve: [ - 'hierarchy.lvl0', - 'hierarchy.lvl1', - 'hierarchy.lvl2', - 'hierarchy.lvl3', - 'hierarchy.lvl4', - 'hierarchy.lvl5', - 'hierarchy.lvl6', - 'content', - 'type', - 'url', - ], - hitsPerPage: 20, - snippetEllipsisText: '…', - highlightPreTag: '<ɵ>', - highlightPostTag: '', - attributesToHighlight: [], - attributesToSnippet: [ - 'hierarchy.lvl1:10', - 'hierarchy.lvl2:10', - 'hierarchy.lvl3:10', - 'hierarchy.lvl4:10', - 'hierarchy.lvl5:10', - 'hierarchy.lvl6:10', - 'content:10', - ], - }, - type: 'default', - }, - ]), - ) - : of(undefined); - }), - ); + return this.client + .search([ + { + indexName: this.config.algolia.indexName, + params: { + query: query, + maxValuesPerFacet: MAX_VALUE_PER_FACET, + attributesToRetrieve: [ + 'hierarchy.lvl0', + 'hierarchy.lvl1', + 'hierarchy.lvl2', + 'hierarchy.lvl3', + 'hierarchy.lvl4', + 'hierarchy.lvl5', + 'hierarchy.lvl6', + 'content', + 'type', + 'url', + ], + hitsPerPage: 20, + snippetEllipsisText: '…', + highlightPreTag: '<ɵ>', + highlightPostTag: '', + attributesToHighlight: [], + attributesToSnippet: [ + 'hierarchy.lvl1:10', + 'hierarchy.lvl2:10', + 'hierarchy.lvl3:10', + 'hierarchy.lvl4:10', + 'hierarchy.lvl5:10', + 'hierarchy.lvl6:10', + 'content:10', + ], + }, + type: 'default', + }, + ]) + .then((response: SearchResponses) => { + return this.parseResult(response); + }); + }, + }); constructor() { - afterNextRender(() => { - this.listenToSearchResults(); - this.resetSearchQueryOnNavigationEnd(); - }); - } - - updateSearchQuery(query: string): void { - this._searchQuery.set(query); - } - - private listenToSearchResults(): void { - this.searchResults$.subscribe((response: any) => { - this._searchResults.set( - response ? this.getUniqueSearchResultItems(response.results[0].hits) : undefined, - ); - }); + this.resetSearchQueryOnNavigationEnd(); } private getUniqueSearchResultItems(items: SearchResult[]): SearchResult[] { @@ -115,8 +125,9 @@ export class Search { ) { return false; } - if (item.url && !uniqueUrls.has(item.url)) { - uniqueUrls.add(item.url); + + if (item['url'] && typeof item['url'] === 'string' && !uniqueUrls.has(item['url'])) { + uniqueUrls.add(item['url']); return true; } return false; @@ -124,8 +135,115 @@ export class Search { } private resetSearchQueryOnNavigationEnd(): void { - this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => { - this.updateSearchQuery(''); + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.searchQuery.set(''); + }); + } + + private parseResult(response: SearchResponses): SearchResultItem[] | undefined { + if (!response) { + return; + } + + const result: AlgoliaSearchResult = response.results[0]; + if (!result || !('hits' in result)) { + return; + } + + const items = result.hits as unknown as SearchResult[]; + + return this.getUniqueSearchResultItems(items).map((hitItem: SearchResult) => { + const content = hitItem._snippetResult.content; + const hierarchy = hitItem._snippetResult.hierarchy; + const hasSubLabel = content || hierarchy?.lvl2 || hierarchy?.lvl3 || hierarchy?.lvl4; + + return { + id: hitItem.objectID, + type: hitItem.hierarchy.lvl0 === 'Tutorials' ? 'code' : 'doc', + url: hitItem.url, + + labelHtml: this.parseLabelToHtml(hitItem._snippetResult.hierarchy?.lvl1?.value ?? ''), + subLabelHtml: this.parseLabelToHtml( + hasSubLabel ? this.getBestSnippetForMatch(hitItem) : null, + ), + + category: hitItem.hierarchy?.lvl0 ?? null, + }; }); } + + private getBestSnippetForMatch(result: SearchResult): string { + // if there is content, return it + if (result._snippetResult.content !== undefined) { + return result._snippetResult.content.value; + } + + const hierarchy = result._snippetResult.hierarchy; + if (hierarchy === undefined) { + return ''; + } + + // return the most specific subheader match + if (matched(hierarchy.lvl4)) { + return hierarchy.lvl4!.value; + } + if (matched(hierarchy.lvl3)) { + return hierarchy.lvl3!.value; + } + if (matched(hierarchy.lvl2)) { + return hierarchy.lvl2!.value; + } + // if no subheader matched the query, fall back to just returning the most specific one + return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? ''; + } + + /** + * Returns an HTML string with marked text for the matches + */ + private parseLabelToHtml(label: string | null): string | null { + if (label === null) { + return null; + } + + const parts: Array<{highlight: boolean; text: string}> = []; + while (label.indexOf('<ɵ>') !== -1) { + const beforeMatch = label.substring(0, label.indexOf('<ɵ>')); + const match = label.substring(label.indexOf('<ɵ>') + 3, label.indexOf('')); + parts.push({highlight: false, text: beforeMatch}); + parts.push({highlight: true, text: match}); + label = label.substring(label.indexOf('') + 4); + } + parts.push({highlight: false, text: label}); + + return parts + .map((part) => { + return part.highlight ? `${part.text}` : `${part.text}`; + }) + .join(''); + } +} + +function matched(snippet: SnippetResult | undefined): boolean { + return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none'; +} + +/** + * Temporary helper to implement the debounce functionality on the search resource + */ +function wait(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => resolve(), ms); + + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeout); + reject(new Error('Operation aborted')); + }, + {once: true}, + ); + }); } diff --git a/adev/src/app/app.config.ts b/adev/src/app/app.config.ts index 94c1098a6d8..868eed181de 100644 --- a/adev/src/app/app.config.ts +++ b/adev/src/app/app.config.ts @@ -21,6 +21,7 @@ import { ENVIRONMENT, EXAMPLE_VIEWER_CONTENT_LOADER, PREVIEWS_COMPONENTS, + provideAlgoliaSearchClient, WINDOW, windowProvider, } from '@angular/docs'; @@ -76,6 +77,7 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withFetch()), provideEnvironmentInitializer(() => inject(AppScroller)), provideEnvironmentInitializer(() => inject(AnalyticsService)), + provideAlgoliaSearchClient(environment), { provide: CURRENT_MAJOR_VERSION, useValue: Number(VERSION.major),