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 {
-
-
- @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),