feat(search): highlight search result terms
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
...
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
// ...
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+28
-3
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+157
-6
@@ -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: {
|
||||
|
||||
+94
-13
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
+39
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+62
@@ -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';
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
export { searchApiRef, MockSearchApi } from './api';
|
||||
export type { SearchApi } from './api';
|
||||
export * from './components';
|
||||
export {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}</>;
|
||||
|
||||
Reference in New Issue
Block a user