Skip to content

Commit 440b1ad

Browse files
JeanMechethePunderWoman
authored andcommitted
docs(docs-infra): Search results as HTML (angular#60394)
fixes angular#60384 PR Close angular#60394
1 parent da9f509 commit 440b1ad

File tree

8 files changed

+286
-208
lines changed

8 files changed

+286
-208
lines changed

adev/shared-docs/components/search-dialog/search-dialog.component.html

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@
33
<docs-text-field
44
[autofocus]="true"
55
[hideIcon]="true"
6-
[ngModel]="searchQuery()"
7-
(ngModelChange)="updateSearchQuery($event)"
6+
[(ngModel)]="searchQuery"
87
class="docs-search-input"
98
placeholder="Search docs"
109
/>
1110

12-
@if (searchResults() && searchResults()!.length > 0) {
11+
@if (searchResults.hasValue() && searchResults.value().length > 0) {
1312
<ul class="docs-search-results docs-mini-scroll-track">
14-
@for (result of searchResults(); track result.objectID) {
13+
@for (result of searchResults.value(); track result.id) {
1514
<li docsSearchItem [item]="result">
1615
<a
1716
[routerLink]="'/' + result.url | relativeLink: 'pathname'"
@@ -22,45 +21,35 @@
2221
<!-- Icon -->
2322
<span class="docs-search-result-icon" aria-hidden="true">
2423
<i role="presentation" class="material-symbols-outlined docs-icon-small">
25-
{{ result.hierarchy.lvl0 === 'Tutorials' ? 'code' : 'description'}}
24+
{{ result.type === 'code' ? 'code' : 'description'}}
2625
</i>
2726
</span>
2827
<!-- Results type -->
29-
<span class="docs-search-results__type">
30-
@let snippet = result._snippetResult.hierarchy?.lvl1?.value ?? '';
31-
<ng-container
32-
[ngTemplateOutlet]="highlightSnippet"
33-
[ngTemplateOutletContext]="{snippet}"
34-
/>
35-
</span>
28+
<span class="docs-search-results__type" [innerHtml]="result.labelHtml"></span>
3629
</div>
3730

38-
@let content = result._snippetResult.content;
39-
@let hierarchy = result._snippetResult.hierarchy;
40-
@if (content || hierarchy?.lvl2 || hierarchy?.lvl3 || hierarchy?.lvl4) {
41-
<span class="docs-search-results__type docs-search-results__lvl2">
42-
@let snippet = getBestSnippetForMatch(result);
43-
<ng-container
44-
[ngTemplateOutlet]="highlightSnippet"
45-
[ngTemplateOutletContext]="{snippet}"
46-
/>
31+
@if (result.subLabelHtml) {
32+
<span
33+
class="docs-search-results__type docs-search-results__lvl2"
34+
[innerHtml]="result.subLabelHtml"
35+
>
4736
</span>
4837
}
4938
</div>
5039

5140
<!-- Page title -->
52-
<span class="docs-result-page-title">{{ result.hierarchy?.lvl0 }}</span>
41+
<span class="docs-result-page-title">{{ result.category }}</span>
5342
</a>
5443
</li>
5544
}
5645
</ul>
5746
} @else {
5847
<div class="docs-search-results docs-mini-scroll-track">
59-
@if (searchResults() === undefined) {
48+
@if (!searchResults.hasValue()) {
6049
<div class="docs-search-results__start-typing">
6150
<span>Start typing to see results</span>
6251
</div>
63-
} @else if (searchResults()?.length === 0) {
52+
} @else if (searchResults.value().length === 0) {
6453
<div class="docs-search-results__no-results">
6554
<span>No results found</span>
6655
</div>
@@ -70,21 +59,13 @@
7059

7160
<div class="docs-algolia">
7261
<span>Search by</span>
73-
<a target="_blank" rel="noopener"
74-
href="https://www.algolia.com/developers/?utm_source=angular.dev&utm_medium=referral&utm_content=powered_by&utm_campaign=docsearch">
62+
<a
63+
target="_blank"
64+
rel="noopener"
65+
href="https://www.algolia.com/developers/?utm_source=angular.dev&utm_medium=referral&utm_content=powered_by&utm_campaign=docsearch"
66+
>
7567
<docs-algolia-icon />
7668
</a>
7769
</div>
7870
</div>
7971
</dialog>
80-
81-
<ng-template #highlightSnippet let-snippet="snippet">
82-
@let parts = splitMarkedText(snippet);
83-
@for (part of parts; track $index) {
84-
@if (part.highlight) {
85-
<mark>{{part.text}}</mark>
86-
} @else {
87-
<span>{{part.text}}</span>
88-
}
89-
}
90-
</ng-template>

adev/shared-docs/components/search-dialog/search-dialog.component.scss

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,17 @@ dialog {
5151
padding-inline-end: 1rem;
5252
padding-block: 0.25rem;
5353

54-
mark {
55-
background: #e62600;
56-
background: var(--red-to-orange-horizontal-gradient);
57-
background-clip: text;
58-
-webkit-background-clip: text;
59-
color: transparent;
54+
/**
55+
* This rule needs ng-deep to be applied to elements that are created via a [innerHTML] binding
56+
*/
57+
::ng-deep {
58+
mark {
59+
background: #e62600;
60+
background: var(--red-to-orange-horizontal-gradient);
61+
background-clip: text;
62+
-webkit-background-clip: text;
63+
color: transparent;
64+
}
6065
}
6166

6267
a {

adev/shared-docs/components/search-dialog/search-dialog.component.spec.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,54 +9,59 @@
99
import {ComponentFixture, TestBed} from '@angular/core/testing';
1010

1111
import {SearchDialog} from './search-dialog.component';
12-
import {WINDOW} from '../../providers';
13-
import {Search} from '../../services';
12+
import {ENVIRONMENT, WINDOW} from '../../providers';
13+
import {ALGOLIA_CLIENT, Search} from '../../services';
1414
import {FakeEventTarget} from '../../testing/index';
1515
import {By} from '@angular/platform-browser';
1616
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
1717
import {RouterTestingModule} from '@angular/router/testing';
1818
import {Router} from '@angular/router';
19-
import {provideExperimentalZonelessChangeDetection} from '@angular/core';
19+
import {
20+
ApplicationRef,
21+
provideExperimentalZonelessChangeDetection,
22+
ResourceStatus,
23+
} from '@angular/core';
2024
import {SearchResult} from '../../interfaces';
2125

2226
describe('SearchDialog', () => {
2327
let fixture: ComponentFixture<SearchDialog>;
2428

25-
const fakeSearch = {
26-
searchQuery: jasmine.createSpy(),
27-
searchResults: jasmine.createSpy(),
28-
};
29+
const searchResults = jasmine.createSpy();
30+
2931
const fakeWindow = new FakeEventTarget();
3032

33+
let search: Search;
34+
3135
beforeEach(async () => {
32-
fakeSearch.searchResults.and.returnValue([]);
33-
fakeSearch.searchQuery.and.returnValue('');
36+
searchResults.and.returnValue([]);
3437

35-
await TestBed.configureTestingModule({
38+
TestBed.configureTestingModule({
3639
imports: [SearchDialog, RouterTestingModule],
3740
providers: [
3841
provideExperimentalZonelessChangeDetection(),
39-
{
40-
provide: Search,
41-
useValue: fakeSearch,
42-
},
43-
{
44-
provide: WINDOW,
45-
useValue: fakeWindow,
46-
},
42+
{provide: ENVIRONMENT, useValue: {algolia: {index: 'fakeIndex'}}},
43+
{provide: ALGOLIA_CLIENT, useValue: {search: searchResults}},
44+
{provide: WINDOW, useValue: fakeWindow},
4745
],
48-
}).compileComponents();
46+
});
4947

5048
fixture = TestBed.createComponent(SearchDialog);
5149
fixture.detectChanges();
50+
search = TestBed.inject(Search);
5251
});
5352

54-
it('should navigate to active item when user pressed Enter', () => {
53+
it('should navigate to active item when user pressed Enter', async () => {
5554
const router = TestBed.inject(Router);
5655
const navigateByUrlSpy = spyOn(router, 'navigateByUrl');
5756

58-
fakeSearch.searchResults.and.returnValue(fakeSearchResults);
59-
fixture.detectChanges();
57+
search.searchQuery.set('fakeQuery');
58+
searchResults.and.returnValue(Promise.resolve({results: [{hits: fakeSearchResults}]}));
59+
60+
// Fire the request
61+
TestBed.inject(ApplicationRef).tick();
62+
63+
// Wait for the resource to resolve
64+
await TestBed.inject(ApplicationRef).whenStable();
6065

6166
fakeWindow.dispatchEvent(
6267
new KeyboardEvent('keydown', {
@@ -78,9 +83,15 @@ describe('SearchDialog', () => {
7883
expect(algoliaIcon).toBeTruthy();
7984
});
8085

81-
it('should display `No results found` message when there are no results for provided query', () => {
82-
fakeSearch.searchResults.and.returnValue([]);
83-
fixture.detectChanges();
86+
it('should display `No results found` message when there are no results for provided query', async () => {
87+
search.searchQuery.set('fakeQuery');
88+
searchResults.and.returnValue(Promise.resolve({results: [{hits: []}]}));
89+
90+
// Fire the request
91+
TestBed.inject(ApplicationRef).tick();
92+
93+
// Wait for the resource to resolve
94+
await TestBed.inject(ApplicationRef).whenStable();
8495

8596
const noResultsContainer = fixture.debugElement.query(
8697
By.css('.docs-search-results__no-results'),
@@ -90,7 +101,7 @@ describe('SearchDialog', () => {
90101
});
91102

92103
it('should display `Start typing to see results` message when there are no provided query', () => {
93-
fakeSearch.searchResults.and.returnValue(undefined);
104+
searchResults.and.returnValue(undefined);
94105
fixture.detectChanges();
95106

96107
const startTypingContainer = fixture.debugElement.query(
@@ -100,9 +111,15 @@ describe('SearchDialog', () => {
100111
expect(startTypingContainer).toBeTruthy();
101112
});
102113

103-
it('should display list of the search results when results exist', () => {
104-
fakeSearch.searchResults.and.returnValue(fakeSearchResults);
105-
fixture.detectChanges();
114+
it('should display list of the search results when results exist', async () => {
115+
search.searchQuery.set('fakeQuery');
116+
searchResults.and.returnValue(Promise.resolve({results: [{hits: fakeSearchResults}]}));
117+
118+
// Fire the request
119+
TestBed.inject(ApplicationRef).tick();
120+
121+
// Wait for the resource to resolve
122+
await TestBed.inject(ApplicationRef).whenStable();
106123

107124
const resultListContainer = fixture.debugElement.query(By.css('ul.docs-search-results'));
108125
const resultItems = fixture.debugElement.queryAll(By.css('ul.docs-search-results li a'));

adev/shared-docs/components/search-dialog/search-dialog.component.ts

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ import {
1212
ElementRef,
1313
Injector,
1414
OnDestroy,
15-
Signal,
1615
afterNextRender,
1716
effect,
1817
inject,
1918
output,
2019
viewChild,
2120
viewChildren,
2221
} from '@angular/core';
23-
import {NgTemplateOutlet} from '@angular/common';
2422

2523
import {WINDOW} from '../../providers/index';
2624
import {ClickOutside} from '../../directives/index';
@@ -32,10 +30,9 @@ import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
3230
import {SearchItem} from '../../directives/search-item/search-item.directive';
3331
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
3432
import {Router, RouterLink} from '@angular/router';
35-
import {filter, fromEvent} from 'rxjs';
33+
import {fromEvent} from 'rxjs';
3634
import {AlgoliaIcon} from '../algolia-icon/algolia-icon.component';
3735
import {RelativeLink} from '../../pipes/relative-link.pipe';
38-
import {SearchResult, SnippetResult} from '../../interfaces';
3936

4037
@Component({
4138
selector: 'docs-search-dialog',
@@ -48,7 +45,6 @@ import {SearchResult, SnippetResult} from '../../interfaces';
4845
AlgoliaIcon,
4946
RelativeLink,
5047
RouterLink,
51-
NgTemplateOutlet,
5248
],
5349
templateUrl: './search-dialog.component.html',
5450
styleUrls: ['./search-dialog.component.scss'],
@@ -106,46 +102,6 @@ export class SearchDialog implements OnDestroy {
106102
});
107103
}
108104

109-
splitMarkedText(snippet: string): Array<{highlight: boolean; text: string}> {
110-
const parts: Array<{highlight: boolean; text: string}> = [];
111-
while (snippet.indexOf('<ɵ>') !== -1) {
112-
const beforeMatch = snippet.substring(0, snippet.indexOf('<ɵ>'));
113-
const match = snippet.substring(snippet.indexOf('<ɵ>') + 3, snippet.indexOf('</ɵ>'));
114-
parts.push({highlight: false, text: beforeMatch});
115-
parts.push({highlight: true, text: match});
116-
snippet = snippet.substring(snippet.indexOf('</ɵ>') + 4);
117-
}
118-
parts.push({highlight: false, text: snippet});
119-
return parts;
120-
}
121-
122-
getBestSnippetForMatch(result: SearchResult): string {
123-
// if there is content, return it
124-
if (result._snippetResult.content !== undefined) {
125-
return result._snippetResult.content.value;
126-
}
127-
128-
const hierarchy = result._snippetResult.hierarchy;
129-
if (hierarchy === undefined) {
130-
return '';
131-
}
132-
function matched(snippet: SnippetResult | undefined) {
133-
return snippet?.matchLevel !== undefined && snippet.matchLevel !== 'none';
134-
}
135-
// return the most specific subheader match
136-
if (matched(hierarchy.lvl4)) {
137-
return hierarchy.lvl4!.value;
138-
}
139-
if (matched(hierarchy.lvl3)) {
140-
return hierarchy.lvl3!.value;
141-
}
142-
if (matched(hierarchy.lvl2)) {
143-
return hierarchy.lvl2!.value;
144-
}
145-
// if no subheader matched the query, fall back to just returning the most specific one
146-
return hierarchy.lvl3?.value ?? hierarchy.lvl2?.value ?? '';
147-
}
148-
149105
ngOnDestroy(): void {
150106
this.keyManager.destroy();
151107
}
@@ -155,10 +111,6 @@ export class SearchDialog implements OnDestroy {
155111
this.onClose.emit();
156112
}
157113

158-
updateSearchQuery(query: string) {
159-
this.search.updateSearchQuery(query);
160-
}
161-
162114
private navigateToTheActiveItem(): void {
163115
const activeItemLink: string | undefined = this.keyManager.activeItem?.item?.url;
164116

adev/shared-docs/directives/search-item/search-item.directive.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import {Directive, ElementRef, Input, inject, signal} from '@angular/core';
1010
import {Highlightable} from '@angular/cdk/a11y';
11-
import {SearchResult} from '../../interfaces/search-results';
11+
import {SearchResultItem} from '../../interfaces';
1212

1313
@Directive({
1414
selector: '[docsSearchItem]',
@@ -17,7 +17,7 @@ import {SearchResult} from '../../interfaces/search-results';
1717
},
1818
})
1919
export class SearchItem implements Highlightable {
20-
@Input() item?: SearchResult;
20+
@Input() item?: SearchResultItem;
2121
@Input() disabled = false;
2222

2323
private readonly elementRef = inject(ElementRef<HTMLLIElement>);
@@ -36,14 +36,6 @@ export class SearchItem implements Highlightable {
3636
this._isActive.set(false);
3737
}
3838

39-
getLabel(): string {
40-
if (!this.item?.hierarchy) {
41-
return '';
42-
}
43-
const {hierarchy} = this.item;
44-
return `${hierarchy.lvl0}${hierarchy.lvl1}${hierarchy.lvl2}`;
45-
}
46-
4739
scrollIntoView(): void {
4840
this.elementRef?.nativeElement.scrollIntoView({block: 'nearest'});
4941
}

adev/shared-docs/interfaces/search-results.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,14 @@ export interface Hierarchy {
5454
lvl5: string | null;
5555
lvl6: string | null;
5656
}
57+
58+
/** Parsed & Structured search results */
59+
export interface SearchResultItem {
60+
type: 'doc' | 'code';
61+
labelHtml: string | null;
62+
subLabelHtml: string | null;
63+
url: string;
64+
65+
id: string;
66+
category: string | null;
67+
}

0 commit comments

Comments
 (0)