feat(search): highlight search result terms

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2022-04-20 00:05:52 -04:00
parent 894f4022b6
commit 3a74e203a8
42 changed files with 947 additions and 69 deletions
+40
View File
@@ -0,0 +1,40 @@
---
'@backstage/create-app': patch
---
Implement highlighting matching terms in search results. To enable this for an existing app, make the following changes:
```diff
// packages/app/src/components/search/SearchPage.tsx
...
- {results.map(({ type, document }) => {
+ {results.map(({ type, document, highlight }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
+ highlight={highlight}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
key={document.location}
result={document}
+ highlight={highlight}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
+ highlight={highlight}
/>
);
}
})}
...
```
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-search': patch
'@backstage/plugin-search-react': patch
'@backstage/plugin-techdocs': patch
---
Updated search result components to support rendering content with highlighted matched terms
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': patch
'@backstage/plugin-search-backend-node': patch
'@backstage/plugin-search-common': patch
---
Support generating highlighted matched terms in search result data
+3
View File
@@ -71,6 +71,7 @@ export const searchPage = (
<CatalogResultListItem
key={result.document.location}
result={result.document}
highlight={result.highlight}
/>
);
default:
@@ -78,6 +79,7 @@ export const searchPage = (
<DefaultResultListItem
key={result.document.location}
result={result.document}
highlight={result.highlight}
/>
);
}
@@ -267,6 +269,7 @@ an example:
<CatalogResultListItem
key={result.document.location}
result={result.document}
highlight={result.highlight}
/>
);
// ...
+23
View File
@@ -398,6 +398,29 @@ export class YourSearchEngine implements SearchEngine {
}
```
## How to customize search results highlighting styling
The default highlighting styling for matched terms in search results is your
browsers default styles for the `<mark>` HTML tag. If you want to customize
how highlighted terms look you can follow Backstage's guide on how to
[Customize the look-and-feel of your App](https://backstage.io/docs/getting-started/app-custom-theme)
to create an override with your preferred styling.
For example, the following will result in highlighted terms to be bold & underlined:
```jsx
const highlightOverride = {
BackstageHighlightedSearchResultText: {
highlight: {
color: 'inherit',
backgroundColor: 'inherit',
fontWeight: 'bold',
textDecoration: 'underline',
},
},
};
```
[obj-mode]: https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_object_mode
[read-stream]: https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_readable_streams
[async-gen]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_async_generators
@@ -180,7 +180,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
{results.map(({ type, document, highlight }) => {
let resultItem;
switch (type) {
case 'software-catalog':
@@ -188,6 +188,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
break;
@@ -196,6 +197,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
break;
@@ -204,6 +206,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
}
@@ -132,13 +132,14 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
{results.map(({ type, document, highlight }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
case 'techdocs':
@@ -146,6 +147,7 @@ const SearchPage = () => {
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
default:
@@ -153,6 +155,7 @@ const SearchPage = () => {
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
}
@@ -112,13 +112,14 @@ const SearchPage = () => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
{results.map(({ type, document, highlight }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
case 'techdocs':
@@ -126,6 +127,7 @@ const SearchPage = () => {
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
default:
@@ -133,6 +135,7 @@ const SearchPage = () => {
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
);
}
+3
View File
@@ -17,6 +17,7 @@ import { Observable } from '@backstage/types';
import { Overrides } from '@material-ui/core/styles/overrides';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { RouteRef } from '@backstage/core-plugin-api';
import { StarredEntitiesApi } from '@backstage/plugin-catalog-react';
import { StorageApi } from '@backstage/core-plugin-api';
@@ -108,6 +109,8 @@ export function CatalogSearchResultListItem(
// @public
export interface CatalogSearchResultListItemProps {
// (undocumented)
highlight?: ResultHighlight;
// (undocumented)
result: IndexableDocument;
}
+1
View File
@@ -43,6 +43,7 @@
"@backstage/plugin-catalog-common": "^1.0.1",
"@backstage/plugin-catalog-react": "^1.1.0-next.1",
"@backstage/plugin-search-common": "^0.3.3",
"@backstage/plugin-search-react": "^0.2.0-next.1",
"@backstage/theme": "^0.2.15",
"@backstage/types": "^1.0.0",
"@material-ui/core": "^4.12.2",
@@ -24,7 +24,11 @@ import {
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { IndexableDocument } from '@backstage/plugin-search-common';
import {
IndexableDocument,
ResultHighlight,
} from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
const useStyles = makeStyles({
flexContainer: {
@@ -44,6 +48,7 @@ const useStyles = makeStyles({
*/
export interface CatalogSearchResultListItemProps {
result: IndexableDocument;
highlight?: ResultHighlight;
}
/** @public */
@@ -59,8 +64,28 @@ export function CatalogSearchResultListItem(
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
secondary={result.text}
primary={
props.highlight?.fields.title ? (
<HighlightedSearchResultText
text={props.highlight.fields.title}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.title
)
}
secondary={
props.highlight?.fields.text ? (
<HighlightedSearchResultText
text={props.highlight.fields.text}
preTag={props.highlight.preTag}
postTag={props.highlight.postTag}
/>
) : (
result.text
)
}
/>
<Box>
{result.kind && <Chip label={`Kind: ${result.kind}`} size="small" />}
@@ -98,6 +98,27 @@ export interface ElasticSearchClientOptions {
Transport?: ElasticSearchTransportConstructor;
}
// @public (undocumented)
export type ElasticSearchHighlightConfig = {
fragmentDelimiter: string;
fragmentSize: number;
numFragments: number;
preTag: string;
postTag: string;
};
// @public (undocumented)
export type ElasticSearchHighlightOptions = {
fragmentDelimiter?: string;
fragmentSize?: number;
numFragments?: number;
};
// @public (undocumented)
export type ElasticSearchQueryTranslatorOptions = {
highlightOptions?: ElasticSearchHighlightConfig;
};
// @public (undocumented)
export class ElasticSearchSearchEngine implements SearchEngine {
constructor(
@@ -105,6 +126,7 @@ export class ElasticSearchSearchEngine implements SearchEngine {
aliasPostfix: string,
indexPrefix: string,
logger: Logger,
highlightOptions?: ElasticSearchHighlightOptions,
);
// Warning: (ae-forgotten-export) The symbol "ElasticSearchOptions" needs to be exported by the entry point index.d.ts
//
@@ -127,7 +149,10 @@ export class ElasticSearchSearchEngine implements SearchEngine {
// Warning: (ae-forgotten-export) The symbol "ConcreteElasticSearchQuery" needs to be exported by the entry point index.d.ts
//
// (undocumented)
protected translator(query: SearchQuery): ConcreteElasticSearchQuery;
protected translator(
query: SearchQuery,
options?: ElasticSearchQueryTranslatorOptions,
): ConcreteElasticSearchQuery;
}
// Warning: (ae-missing-release-tag) "ElasticSearchSearchEngineIndexer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+18
View File
@@ -21,6 +21,24 @@ export interface Config {
* Options for ElasticSearch
*/
elasticsearch?: {
/**
* Options for configuring highlight settings
* See https://www.elastic.co/guide/en/elasticsearch/reference/7.17/highlighting.html
*/
highlightOptions?: {
/**
* The size of the highlighted fragment in characters. Defaults to 1000.
*/
fragmentSize?: number;
/**
* Number of result fragments to extract. Fragments will be concatenated with `fragmentDelimiter`. Defaults to 1.
*/
numFragments?: number;
/**
* Delimiter string used to concatenate fragments. Defaults to " ... ".
*/
fragmentDelimiter?: string;
};
/** Miscellaneous options for the client */
clientOptions?: {
ssl?: {
@@ -23,14 +23,15 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@acuris/aws-es-connection": "^2.2.0",
"@backstage/config": "^1.0.0",
"@backstage/plugin-search-backend-node": "^0.6.1-next.0",
"@backstage/plugin-search-common": "^0.3.3",
"@elastic/elasticsearch": "7.13.0",
"@acuris/aws-es-connection": "^2.2.0",
"aws-sdk": "^2.948.0",
"elastic-builder": "^2.16.0",
"lodash": "^4.17.21",
"uuid": "^8.3.2",
"winston": "^3.2.1"
},
"devDependencies": {
@@ -26,6 +26,8 @@ import {
} from './ElasticSearchSearchEngine';
import { ElasticSearchSearchEngineIndexer } from './ElasticSearchSearchEngineIndexer';
jest.mock('uuid', () => ({ v4: () => 'tag' }));
class ElasticSearchSearchEngineForTranslatorTests extends ElasticSearchSearchEngine {
getTranslator() {
return this.translator;
@@ -105,10 +107,21 @@ describe('ElasticSearchSearchEngine', () => {
filters: {},
});
expect(translatorSpy).toHaveBeenCalledWith({
term: 'testTerm',
filters: {},
});
expect(translatorSpy).toHaveBeenCalledWith(
{
term: 'testTerm',
filters: {},
},
{
highlightOptions: {
preTag: `<tag>`,
postTag: `</tag>`,
fragmentSize: 1000,
numFragments: 1,
fragmentDelimiter: ' ... ',
},
},
);
});
it('should return translated query with 1 filter', async () => {
@@ -281,6 +294,59 @@ describe('ElasticSearchSearchEngine', () => {
});
});
it('should accept custom highlight options', async () => {
const translatorUnderTest = inspectableSearchEngine.getTranslator();
const actualTranslatedQuery = translatorUnderTest(
{
types: ['indexName'],
term: 'testTerm',
pageCursor: 'MQ==',
},
{
highlightOptions: {
preTag: `<custom-tag>`,
postTag: `</custom-tag>`,
fragmentSize: 100,
numFragments: 3,
fragmentDelimiter: ' ... ',
},
},
) as ConcreteElasticSearchQuery;
expect(actualTranslatedQuery).toMatchObject({
documentTypes: ['indexName'],
elasticSearchQuery: expect.any(Object),
});
const queryBody = actualTranslatedQuery.elasticSearchQuery;
expect(queryBody).toEqual({
query: {
bool: {
filter: [],
must: {
multi_match: {
query: 'testTerm',
fields: ['*'],
fuzziness: 'auto',
minimum_should_match: 1,
},
},
},
},
highlight: {
fields: { '*': {} },
fragment_size: 100,
number_of_fragments: 3,
pre_tags: ['<custom-tag>'],
post_tags: ['</custom-tag>'],
},
from: 25,
size: 25,
});
});
it('should throw if unsupported filter shapes passed in', async () => {
const translatorUnderTest = inspectableSearchEngine.getTranslator();
const actualTranslatedQuery = () =>
@@ -465,6 +531,66 @@ describe('ElasticSearchSearchEngine', () => {
});
});
it('should handle parsing highlights in search query results', async () => {
mock.clear({
method: 'POST',
path: '/*__search/_search',
});
mock.add(
{
method: 'POST',
path: '/*__search/_search',
},
() => {
return {
hits: {
total: { value: 30, relation: 'eq' },
hits: Array(25)
.fill(null)
.map((_, i) => ({
_index: 'mytype-index__',
_source: {
value: `${i}`,
},
highlight: {
foo: [
'highlighted <tag>test</tag> result',
'another <tag>fragment</tag> result',
],
bar: ['more <tag>test</tag> results'],
},
})),
},
};
},
);
const mockedSearchResult = await testSearchEngine.query({
term: 'testTerm',
filters: {},
});
expect(mockedSearchResult).toMatchObject({
results: expect.arrayContaining(
Array(25)
.fill(null)
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
highlight: {
preTag: '<tag>',
postTag: '</tag>',
fields: {
foo: 'highlighted <tag>test</tag> result ... another <tag>fragment</tag> result',
bar: 'more <tag>test</tag> results',
},
},
})),
),
nextPageCursor: 'MQ==',
});
});
it('should handle index/search type filtering correctly', async () => {
const elasticSearchQuerySpy = jest.spyOn(client, 'search');
await testSearchEngine.query({
@@ -488,6 +614,13 @@ describe('ElasticSearchSearchEngine', () => {
filter: [],
},
},
highlight: {
fields: { '*': {} },
fragment_size: 1000,
number_of_fragments: 1,
pre_tags: ['<tag>'],
post_tags: ['</tag>'],
},
from: 0,
size: 25,
},
@@ -515,6 +648,13 @@ describe('ElasticSearchSearchEngine', () => {
filter: [],
},
},
highlight: {
fields: { '*': {} },
fragment_size: 1000,
number_of_fragments: 1,
pre_tags: ['<tag>'],
post_tags: ['</tag>'],
},
from: 0,
size: 25,
},
@@ -543,6 +683,13 @@ describe('ElasticSearchSearchEngine', () => {
filter: [],
},
},
highlight: {
fields: { '*': {} },
fragment_size: 1000,
number_of_fragments: 1,
pre_tags: ['<tag>'],
post_tags: ['</tag>'],
},
from: 0,
size: 25,
},
@@ -618,7 +765,7 @@ describe('ElasticSearchSearchEngine', () => {
});
describe('ElasticSearchSearchEngine.fromConfig', () => {
it('accesses the clientOptions config', async () => {
it('accesses the clientOptions and highlightOptions config', async () => {
const esOptions = {
clientOptions: {
ssl: {
@@ -635,6 +782,7 @@ describe('ElasticSearchSearchEngine', () => {
const esConfig = new ConfigReader(esOptions);
jest.spyOn(config, 'getConfig').mockImplementation(() => esConfig);
const getOptionalConfig = jest.spyOn(esConfig, 'getOptionalConfig');
const getOptional = jest.spyOn(config, 'getOptional');
await ElasticSearchSearchEngine.fromConfig({
logger: getVoidLogger(),
@@ -642,9 +790,12 @@ describe('ElasticSearchSearchEngine', () => {
});
expect(getOptionalConfig.mock.calls[0][0]).toEqual('clientOptions');
expect(getOptional.mock.calls[0][0]).toEqual(
'search.elasticsearch.highlightOptions',
);
});
it('does not require the clientOptions config', async () => {
it('does not require the clientOptions or highlightOptions config', async () => {
const config = new ConfigReader({
search: {
elasticsearch: {
@@ -21,6 +21,7 @@ import {
import { Config } from '@backstage/config';
import {
IndexableDocument,
IndexableResult,
IndexableResultSet,
SearchEngine,
SearchQuery,
@@ -28,6 +29,7 @@ import {
import { Client } from '@elastic/elasticsearch';
import esb from 'elastic-builder';
import { isEmpty, isNaN as nan, isNumber } from 'lodash';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import type { ElasticSearchClientOptions } from './ElasticSearchClientOptions';
import { ElasticSearchSearchEngineIndexer } from './ElasticSearchSearchEngineIndexer';
@@ -40,8 +42,16 @@ export type ConcreteElasticSearchQuery = {
pageSize: number;
};
/**
* @public
*/
export type ElasticSearchQueryTranslatorOptions = {
highlightOptions?: ElasticSearchHighlightConfig;
};
type ElasticSearchQueryTranslator = (
query: SearchQuery,
options?: ElasticSearchQueryTranslatorOptions,
) => ConcreteElasticSearchQuery;
type ElasticSearchOptions = {
@@ -51,11 +61,34 @@ type ElasticSearchOptions = {
indexPrefix?: string;
};
/**
* @public
*/
export type ElasticSearchHighlightOptions = {
fragmentDelimiter?: string;
fragmentSize?: number;
numFragments?: number;
};
/**
* @public
*/
export type ElasticSearchHighlightConfig = {
fragmentDelimiter: string;
fragmentSize: number;
numFragments: number;
preTag: string;
postTag: string;
};
type ElasticSearchResult = {
_index: string;
_type: string;
_score: number;
_source: IndexableDocument;
highlight?: {
[field: string]: string[];
};
};
function isBlank(str: string) {
@@ -67,14 +100,25 @@ function isBlank(str: string) {
*/
export class ElasticSearchSearchEngine implements SearchEngine {
private readonly elasticSearchClient: Client;
private readonly highlightOptions: ElasticSearchHighlightConfig;
constructor(
private readonly elasticSearchClientOptions: ElasticSearchClientOptions,
private readonly aliasPostfix: string,
private readonly indexPrefix: string,
private readonly logger: Logger,
highlightOptions?: ElasticSearchHighlightOptions,
) {
this.elasticSearchClient = this.newClient(options => new Client(options));
const uuidTag = uuid();
this.highlightOptions = {
preTag: `<${uuidTag}>`,
postTag: `</${uuidTag}>`,
fragmentSize: 1000,
numFragments: 1,
fragmentDelimiter: ' ... ',
...highlightOptions,
};
}
static async fromConfig({
@@ -99,6 +143,9 @@ export class ElasticSearchSearchEngine implements SearchEngine {
aliasPostfix,
indexPrefix,
logger,
config.getOptional<ElasticSearchHighlightOptions>(
'search.elasticsearch.highlightOptions',
),
);
}
@@ -111,7 +158,10 @@ export class ElasticSearchSearchEngine implements SearchEngine {
return create(this.elasticSearchClientOptions);
}
protected translator(query: SearchQuery): ConcreteElasticSearchQuery {
protected translator(
query: SearchQuery,
options?: ElasticSearchQueryTranslatorOptions,
): ConcreteElasticSearchQuery {
const { term, filters = {}, types, pageCursor } = query;
const filter = Object.entries(filters)
@@ -143,13 +193,25 @@ export class ElasticSearchSearchEngine implements SearchEngine {
const pageSize = 25;
const { page } = decodePageCursor(pageCursor);
let esbRequestBodySearch = esb
.requestBodySearch()
.query(esb.boolQuery().filter(filter).must([esbQuery]))
.from(page * pageSize)
.size(pageSize);
if (options?.highlightOptions) {
esbRequestBodySearch = esbRequestBodySearch.highlight(
esb
.highlight('*')
.numberOfFragments(options.highlightOptions.numFragments as number)
.fragmentSize(options.highlightOptions.fragmentSize as number)
.preTags(options.highlightOptions.preTag)
.postTags(options.highlightOptions.postTag),
);
}
return {
elasticSearchQuery: esb
.requestBodySearch()
.query(esb.boolQuery().filter(filter).must([esbQuery]))
.from(page * pageSize)
.size(pageSize)
.toJSON(),
elasticSearchQuery: esbRequestBodySearch.toJSON(),
documentTypes: types,
pageSize,
};
@@ -193,8 +255,10 @@ export class ElasticSearchSearchEngine implements SearchEngine {
}
async query(query: SearchQuery): Promise<IndexableResultSet> {
const { elasticSearchQuery, documentTypes, pageSize } =
this.translator(query);
const { elasticSearchQuery, documentTypes, pageSize } = this.translator(
query,
{ highlightOptions: this.highlightOptions },
);
const queryIndices = documentTypes
? documentTypes.map(it => this.constructSearchAlias(it))
: this.constructSearchAlias('*');
@@ -214,10 +278,27 @@ export class ElasticSearchSearchEngine implements SearchEngine {
: undefined;
return {
results: result.body.hits.hits.map((d: ElasticSearchResult) => ({
type: this.getTypeFromIndex(d._index),
document: d._source,
})),
results: result.body.hits.hits.map((d: ElasticSearchResult) => {
const resultItem: IndexableResult = {
type: this.getTypeFromIndex(d._index),
document: d._source,
};
if (d.highlight) {
resultItem.highlight = {
preTag: this.highlightOptions.preTag as string,
postTag: this.highlightOptions.postTag as string,
fields: Object.fromEntries(
Object.entries(d.highlight).map(([field, fragments]) => [
field,
fragments.join(this.highlightOptions.fragmentDelimiter),
]),
),
};
}
return resultItem;
}),
nextPageCursor,
previousPageCursor,
};
@@ -18,6 +18,9 @@ export { ElasticSearchSearchEngine } from './ElasticSearchSearchEngine';
export type {
ConcreteElasticSearchQuery,
ElasticSearchClientOptions,
ElasticSearchHighlightConfig,
ElasticSearchHighlightOptions,
ElasticSearchQueryTranslatorOptions,
} from './ElasticSearchSearchEngine';
export type {
ElasticSearchSearchEngineIndexer,
@@ -23,6 +23,9 @@
export { ElasticSearchSearchEngine } from './engines';
export type {
ElasticSearchClientOptions,
ElasticSearchHighlightConfig,
ElasticSearchHighlightOptions,
ElasticSearchQueryTranslatorOptions,
ElasticSearchSearchEngineIndexer,
ElasticSearchSearchEngineIndexerOptions,
} from './engines';
@@ -82,6 +82,10 @@ export class LunrSearchEngine implements SearchEngine {
// (undocumented)
getIndexer(type: string): Promise<LunrSearchEngineIndexer>;
// (undocumented)
protected highlightPostTag: string;
// (undocumented)
protected highlightPreTag: string;
// (undocumented)
protected logger: Logger;
// (undocumented)
protected lunrIndices: Record<string, lunr_2.Index>;
+1
View File
@@ -30,6 +30,7 @@
"lodash": "^4.17.21",
"lunr": "^2.3.9",
"node-abort-controller": "^3.0.1",
"uuid": "^8.3.2",
"winston": "^3.2.1"
},
"devDependencies": {
@@ -25,6 +25,7 @@ import {
LunrSearchEngine,
decodePageCursor,
encodePageCursor,
parseHighlightFields,
} from './LunrSearchEngine';
import { LunrSearchEngineIndexer } from './LunrSearchEngineIndexer';
import { TestPipeline } from '../test-utils';
@@ -33,6 +34,12 @@ import { TestPipeline } from '../test-utils';
* Just used to test the default translator shipped with LunrSearchEngine.
*/
class LunrSearchEngineForTests extends LunrSearchEngine {
getHighlightTags() {
return {
pre: this.highlightPreTag,
post: this.highlightPostTag,
};
}
getDocStore() {
return this.docStore;
}
@@ -468,6 +475,58 @@ describe('LunrSearchEngine', () => {
});
});
it('should perform search query and return highlight metadata on match', async () => {
const inspectableSearchEngine = new LunrSearchEngineForTests({
logger: getVoidLogger(),
});
const mockDocuments = [
{
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
];
// Mock indexing of 1 document
const indexer = await getActualIndexer(
inspectableSearchEngine,
'test-index',
);
await TestPipeline.withSubject(indexer)
.withDocuments(mockDocuments)
.execute();
// Perform search query
const mockedSearchResult = await inspectableSearchEngine.query({
term: 'test',
filters: {},
});
const highlightTags = inspectableSearchEngine.getHighlightTags();
expect(mockedSearchResult).toMatchObject({
results: [
{
document: {
title: 'testTitle',
text: 'testText',
location: 'test/location',
},
highlight: {
preTag: highlightTags.pre,
postTag: highlightTags.post,
fields: {
title: `${highlightTags.pre}testTitle${highlightTags.post}`,
text: `${highlightTags.pre}testText${highlightTags.post}`,
location: `${highlightTags.pre}test/location${highlightTags.post}`,
},
},
},
],
nextPageCursor: undefined,
});
});
it('should perform search query and return search results on partial match', async () => {
const mockDocuments = [
{
@@ -976,3 +1035,33 @@ describe('encodePageCursor', () => {
expect(encodePageCursor({ page: 1 })).toEqual('MQ==');
});
});
describe('parseHighlightFields', () => {
it('should parse highlight metadata', () => {
expect(
parseHighlightFields({
preTag: '<>',
postTag: '</>',
doc: { foo: 'abc def', bar: 'ghi jkl' },
positionMetadata: {
test: {
foo: {
position: [[0, 3]],
},
},
anotherTest: {
foo: {
position: [[4, 3]],
},
bar: {
position: [[4, 3]],
},
},
},
}),
).toEqual({
foo: '<>abc</> <>def</>',
bar: 'ghi <>jkl</>',
});
});
});
@@ -22,6 +22,7 @@ import {
SearchEngine,
} from '@backstage/plugin-search-common';
import lunr from 'lunr';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { LunrSearchEngineIndexer } from './LunrSearchEngineIndexer';
@@ -51,10 +52,15 @@ export class LunrSearchEngine implements SearchEngine {
protected lunrIndices: Record<string, lunr.Index> = {};
protected docStore: Record<string, IndexableDocument>;
protected logger: Logger;
protected highlightPreTag: string;
protected highlightPostTag: string;
constructor({ logger }: { logger: Logger }) {
this.logger = logger;
this.docStore = {};
const uuidTag = uuid();
this.highlightPreTag = `<${uuidTag}>`;
this.highlightPostTag = `</${uuidTag}>`;
}
protected translator: QueryTranslator = ({
@@ -198,9 +204,20 @@ export class LunrSearchEngine implements SearchEngine {
// Translate results into IndexableResultSet
const realResultSet: IndexableResultSet = {
results: results.slice(offset, offset + pageSize).map(d => {
return { type: d.type, document: this.docStore[d.result.ref] };
}),
results: results.slice(offset, offset + pageSize).map(d => ({
type: d.type,
document: this.docStore[d.result.ref],
highlight: {
preTag: this.highlightPreTag,
postTag: this.highlightPostTag,
fields: parseHighlightFields({
preTag: this.highlightPreTag,
postTag: this.highlightPostTag,
doc: this.docStore[d.result.ref],
positionMetadata: d.result.matchData.metadata as any,
}),
},
})),
nextPageCursor,
previousPageCursor,
};
@@ -222,3 +239,52 @@ export function decodePageCursor(pageCursor?: string): { page: number } {
export function encodePageCursor({ page }: { page: number }): string {
return Buffer.from(`${page}`, 'utf-8').toString('base64');
}
type ParseHighlightFieldsProps = {
preTag: string;
postTag: string;
doc: any;
positionMetadata: {
[term: string]: {
[field: string]: {
position: number[][];
};
};
};
};
export function parseHighlightFields({
preTag,
postTag,
doc,
positionMetadata,
}: ParseHighlightFieldsProps): { [field: string]: string } {
// Merge the field positions across all query terms
const highlightFieldPositions = Object.values(positionMetadata).reduce(
(fieldPositions, metadata) => {
Object.keys(metadata).map(fieldKey => {
fieldPositions[fieldKey] = fieldPositions[fieldKey] ?? [];
fieldPositions[fieldKey].push(...metadata[fieldKey].position);
});
return fieldPositions;
},
{} as { [field: string]: number[][] },
);
return Object.fromEntries(
Object.entries(highlightFieldPositions).map(([field, positions]) => {
positions.sort((a, b) => b[0] - a[0]);
const highlightedField = positions.reduce((content, pos) => {
return (
`${content.substring(0, pos[0])}${preTag}` +
`${content.substring(pos[0], pos[0] + pos[1])}` +
`${postTag}${content.substring(pos[0] + pos[1])}`
);
}, doc[field]);
return [field, highlightedField];
}),
);
}
@@ -32,6 +32,7 @@ export class LunrSearchEngineIndexer extends BatchSearchEngineIndexer {
this.builder = new lunr.Builder();
this.builder.pipeline.add(lunr.trimmer, lunr.stopWordFilter, lunr.stemmer);
this.builder.searchPipeline.add(lunr.stemmer);
this.builder.metadataWhitelist = ['position'];
}
// No async initialization required.
+12
View File
@@ -55,9 +55,21 @@ export interface Result<TDocument extends SearchDocument> {
// (undocumented)
document: TDocument;
// (undocumented)
highlight?: ResultHighlight;
// (undocumented)
type: string;
}
// @beta
export interface ResultHighlight {
// (undocumented)
fields: {
[field: string]: string;
};
postTag: string;
preTag: string;
}
// @beta (undocumented)
export interface ResultSet<TDocument extends SearchDocument> {
// (undocumented)
+25
View File
@@ -28,12 +28,37 @@ export interface SearchQuery {
pageCursor?: string;
}
/**
* @beta
* Metadata for result relevant document fields with matched terms highlighted
* via wrapping in associated pre/post tags. The UI is expected to parse these
* field excerpts by replacing wrapping tags with applicable UI elements for rendering.
*/
export interface ResultHighlight {
/**
* Prefix tag for wrapping terms to be highlighted.
*/
preTag: string;
/**
* Postfix tag for wrapping terms to be highlighted.
*/
postTag: string;
fields: {
/**
* Matched document fields and associated excerpts containing highlighted
* terms wrapped in preTag and postTag to be parsed and rendered in the UI.
*/
[field: string]: string;
};
}
/**
* @beta
*/
export interface Result<TDocument extends SearchDocument> {
type: string;
document: TDocument;
highlight?: ResultHighlight;
}
/**
+16
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { JsonObject } from '@backstage/types';
@@ -11,6 +13,20 @@ import { default as React_2 } from 'react';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
// @public (undocumented)
export const HighlightedSearchResultText: ({
text,
preTag,
postTag,
}: HighlightedSearchResultTextProps) => JSX.Element;
// @public (undocumented)
export type HighlightedSearchResultTextProps = {
text: string;
preTag: string;
postTag: string;
};
// @public
export class MockSearchApi implements SearchApi {
constructor(mockedResults?: SearchResultSet | undefined);
+2 -1
View File
@@ -35,7 +35,8 @@
"@backstage/core-plugin-api": "^1.0.2-next.0",
"@backstage/version-bridge": "^1.0.1",
"react-use": "^17.3.2",
"@backstage/types": "^1.0.0"
"@backstage/types": "^1.0.0",
"@material-ui/core": "^4.12.2"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -0,0 +1,39 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { HighlightedSearchResultText } from './HighlightedSearchResultText';
describe('HighlightedSearchResultText', () => {
it('properly highlights result text', async () => {
await renderInTestApp(
<HighlightedSearchResultText
preTag="<tag>"
postTag="</tag>"
text="test <tag>highlighted</tag> restult <tag>text</tag>"
/>,
);
expect(
screen.getByText('highlighted').tagName.toLocaleLowerCase('en-US'),
).toEqual('mark');
expect(screen.getByText('text').tagName.toLocaleLowerCase('en-US')).toEqual(
'mark',
);
});
});
@@ -0,0 +1,62 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useMemo } from 'react';
import { makeStyles } from '@material-ui/core';
const useStyles = makeStyles(
() => ({
highlight: {},
}),
{ name: 'BackstageHighlightedSearchResultText' },
);
/**
* @public
*/
export type HighlightedSearchResultTextProps = {
text: string;
preTag: string;
postTag: string;
};
/**
* @public
*/
export const HighlightedSearchResultText = ({
text,
preTag,
postTag,
}: HighlightedSearchResultTextProps) => {
const classes = useStyles();
const terms = useMemo(
() => text.split(new RegExp(`(${preTag}.+?${postTag})`)),
[postTag, preTag, text],
);
return (
<>
{terms.map((t, idx) =>
t.includes(preTag) ? (
<mark className={classes.highlight} key={idx}>
{t.replace(new RegExp(`${preTag}|${postTag}`, 'g'), '')}
</mark>
) : (
t
),
)}
</>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './HighlightedSearchResultText';
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './HighlightedSearchResultText';
+1
View File
@@ -22,6 +22,7 @@
export { searchApiRef, MockSearchApi } from './api';
export type { SearchApi } from './api';
export * from './components';
export {
SearchContextProvider,
useSearch,
+3
View File
@@ -10,6 +10,7 @@ import { IconComponent } from '@backstage/core-plugin-api';
import { InputBaseProps } from '@material-ui/core';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { RouteRef } from '@backstage/core-plugin-api';
import { SearchDocument } from '@backstage/plugin-search-common';
import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common';
@@ -19,6 +20,7 @@ import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common'
// @public (undocumented)
export const DefaultResultListItem: ({
result,
highlight,
icon,
secondaryAction,
lineClamp,
@@ -26,6 +28,7 @@ export const DefaultResultListItem: ({
icon?: ReactNode;
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight | undefined;
lineClamp?: number | undefined;
}) => JSX.Element;
-1
View File
@@ -49,7 +49,6 @@
"qs": "^6.9.4",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-text-truncate": "^0.18.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
@@ -15,7 +15,10 @@
*/
import { Button } from '@backstage/core-components';
import { lightTheme } from '@backstage/theme';
import { Grid } from '@material-ui/core';
import CssBaseline from '@material-ui/core/CssBaseline';
import { ThemeProvider } from '@material-ui/core/styles';
import FindInPageIcon from '@material-ui/icons/FindInPage';
import GroupIcon from '@material-ui/icons/Group';
import React from 'react';
@@ -77,3 +80,47 @@ export const WithSecondaryAction = () => {
/>
);
};
export const WithHighlightedResults = () => {
return (
<DefaultResultListItem
result={mockSearchResult}
highlight={{
preTag: '<tag>',
postTag: '</tag>',
fields: { text: 'some <tag>text</tag> from the search result' },
}}
/>
);
};
export const WithCustomHighlightedResults = () => {
const customTheme = {
...lightTheme,
overrides: {
...lightTheme.overrides,
BackstageHighlightedSearchResultText: {
highlight: {
color: 'inherit',
backgroundColor: 'inherit',
fontWeight: 'bold',
textDecoration: 'underline',
},
},
},
};
return (
<ThemeProvider theme={customTheme}>
<CssBaseline>
<DefaultResultListItem
result={mockSearchResult}
highlight={{
preTag: '<tag>',
postTag: '</tag>',
fields: { text: 'some <tag>text</tag> from the search result' },
}}
/>
</CssBaseline>
</ThemeProvider>
);
};
@@ -20,11 +20,6 @@ import { renderInTestApp } from '@backstage/test-utils';
import FindInPageIcon from '@material-ui/icons/FindInPage';
import { DefaultResultListItem } from './DefaultResultListItem';
// Using canvas to render text..
jest.mock('react-text-truncate', () => {
return ({ text }: { text: string }) => <span>{text}</span>;
});
describe('DefaultResultListItem', () => {
const result = {
title: 'title',
@@ -15,7 +15,11 @@
*/
import React, { ReactNode } from 'react';
import { SearchDocument } from '@backstage/plugin-search-common';
import {
ResultHighlight,
SearchDocument,
} from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
import {
ListItem,
ListItemIcon,
@@ -24,17 +28,18 @@ import {
Divider,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import TextTruncate from 'react-text-truncate';
type Props = {
icon?: ReactNode;
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight;
lineClamp?: number;
};
export const DefaultResultListItem = ({
result,
highlight,
icon,
secondaryAction,
lineClamp = 5,
@@ -45,14 +50,36 @@ export const DefaultResultListItem = ({
{icon && <ListItemIcon>{icon}</ListItemIcon>}
<ListItemText
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
primary={
highlight?.fields.title ? (
<HighlightedSearchResultText
text={highlight.fields.title}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.title
)
}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
<span
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: lineClamp,
overflow: 'hidden',
}}
>
{highlight?.fields.text ? (
<HighlightedSearchResultText
text={highlight.fields.text}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.text
)}
</span>
}
/>
{secondaryAction && <Box alignItems="flex-end">{secondaryAction}</Box>}
@@ -137,7 +137,7 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
<SearchResult>
{({ results }) => (
<List>
{results.map(({ document }) => (
{results.map(({ document, highlight }) => (
<div
role="button"
tabIndex={0}
@@ -148,6 +148,7 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
</div>
))}
+2
View File
@@ -17,6 +17,7 @@ import { IdentityApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { RouteRef } from '@backstage/core-plugin-api';
import { TableColumn } from '@backstage/core-components';
import { TableProps } from '@backstage/core-components';
@@ -388,6 +389,7 @@ export const TechDocsSearchResultListItem: (
// @public
export type TechDocsSearchResultListItemProps = {
result: any;
highlight?: ResultHighlight;
lineClamp?: number;
asListItem?: boolean;
asLink?: boolean;
+1 -1
View File
@@ -43,6 +43,7 @@
"@backstage/integration": "^1.2.0-next.0",
"@backstage/integration-react": "^1.1.0-next.1",
"@backstage/plugin-catalog-react": "^1.1.0-next.1",
"@backstage/plugin-search-common": "^0.3.3",
"@backstage/plugin-search-react": "^0.2.0-next.1",
"@backstage/plugin-techdocs-react": "^0.1.1-next.1",
"@backstage/theme": "^0.2.15",
@@ -58,7 +59,6 @@
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-text-truncate": "^0.18.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
@@ -143,13 +143,14 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
noOptionsText="No results found"
value={null}
options={options}
renderOption={({ document }) => (
renderOption={({ document, highlight }) => (
<TechDocsSearchResultListItem
result={document}
lineClamp={3}
asListItem={false}
asLink={false}
title={document.title}
highlight={highlight}
/>
)}
loading={loading}
@@ -17,7 +17,8 @@
import React, { PropsWithChildren } from 'react';
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
import { Link } from '@backstage/core-components';
import TextTruncate from 'react-text-truncate';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
const useStyles = makeStyles({
flexContainer: {
@@ -36,6 +37,7 @@ const useStyles = makeStyles({
*/
export type TechDocsSearchResultListItemProps = {
result: any;
highlight?: ResultHighlight;
lineClamp?: number;
asListItem?: boolean;
asLink?: boolean;
@@ -52,31 +54,80 @@ export const TechDocsSearchResultListItem = (
) => {
const {
result,
highlight,
lineClamp = 5,
asListItem = true,
asLink = true,
title,
} = props;
const classes = useStyles();
const TextItem = () => (
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
title
? title
: `${result.title} | ${result.entityTitle ?? result.name} docs`
}
secondary={
<TextTruncate
line={lineClamp}
truncateText="…"
text={result.text}
element="span"
/>
}
/>
);
const TextItem = () => {
const resultTitle = highlight?.fields.title ? (
<HighlightedSearchResultText
text={highlight.fields.title}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.title
);
const entityTitle = highlight?.fields.entityTitle ? (
<HighlightedSearchResultText
text={highlight.fields.entityTitle}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.entityTitle
);
const resultName = highlight?.fields.name ? (
<HighlightedSearchResultText
text={highlight.fields.name}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.name
);
return (
<ListItemText
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
title ? (
title
) : (
<>
{resultTitle} | {entityTitle ?? resultName} docs
</>
)
}
secondary={
<span
style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: lineClamp,
overflow: 'hidden',
}}
>
{highlight?.fields.text ? (
<HighlightedSearchResultText
text={highlight.fields.text}
preTag={highlight.preTag}
postTag={highlight.postTag}
/>
) : (
result.text
)}
</span>
}
/>
);
};
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
asLink ? <Link to={result.location}>{children}</Link> : <>{children}</>;