diff --git a/.changeset/thick-tools-dream.md b/.changeset/thick-tools-dream.md
new file mode 100644
index 0000000000..4979c62787
--- /dev/null
+++ b/.changeset/thick-tools-dream.md
@@ -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 (
+
+ );
+ case 'techdocs':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ })}
+...
+```
diff --git a/.changeset/tricky-laws-thank.md b/.changeset/tricky-laws-thank.md
new file mode 100644
index 0000000000..c9efa0c495
--- /dev/null
+++ b/.changeset/tricky-laws-thank.md
@@ -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
diff --git a/.changeset/warm-suns-wonder.md b/.changeset/warm-suns-wonder.md
new file mode 100644
index 0000000000..9f52889fca
--- /dev/null
+++ b/.changeset/warm-suns-wonder.md
@@ -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
diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md
index 66540bd096..14ad523740 100644
--- a/docs/features/search/getting-started.md
+++ b/docs/features/search/getting-started.md
@@ -71,6 +71,7 @@ export const searchPage = (
);
default:
@@ -78,6 +79,7 @@ export const searchPage = (
);
}
@@ -266,6 +268,7 @@ an example:
);
// ...
diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md
index d0e0fd95e6..64787ec3c1 100644
--- a/docs/features/search/how-to-guides.md
+++ b/docs/features/search/how-to-guides.md
@@ -411,6 +411,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 `` 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
diff --git a/packages/app/src/components/search/SearchModal.tsx b/packages/app/src/components/search/SearchModal.tsx
index f75017a4dd..3fdacf08b6 100644
--- a/packages/app/src/components/search/SearchModal.tsx
+++ b/packages/app/src/components/search/SearchModal.tsx
@@ -180,7 +180,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
{({ results }) => (
- {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 }) => {
);
break;
@@ -196,6 +197,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
);
break;
@@ -204,6 +206,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
);
}
diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx
index fd9c80686c..d1b56bbaf0 100644
--- a/packages/app/src/components/search/SearchPage.tsx
+++ b/packages/app/src/components/search/SearchPage.tsx
@@ -132,13 +132,14 @@ const SearchPage = () => {
{({ results }) => (
- {results.map(({ type, document }) => {
+ {results.map(({ type, document, highlight }) => {
switch (type) {
case 'software-catalog':
return (
);
case 'techdocs':
@@ -146,6 +147,7 @@ const SearchPage = () => {
);
default:
@@ -153,6 +155,7 @@ const SearchPage = () => {
);
}
diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx
index 0595e4ece8..d4c7c92346 100644
--- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx
@@ -112,13 +112,14 @@ const SearchPage = () => {
{({ results }) => (
- {results.map(({ type, document }) => {
+ {results.map(({ type, document, highlight }) => {
switch (type) {
case 'software-catalog':
return (
);
case 'techdocs':
@@ -126,6 +127,7 @@ const SearchPage = () => {
);
default:
@@ -133,6 +135,7 @@ const SearchPage = () => {
);
}
diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md
index e8da88a4e1..556016941a 100644
--- a/plugins/catalog/api-report.md
+++ b/plugins/catalog/api-report.md
@@ -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;
}
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 6d58e0fb4a..4bd16aeeac 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -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",
diff --git a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx
index aeaa13e3fc..5ba9d09949 100644
--- a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx
+++ b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx
@@ -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(
+ ) : (
+ result.title
+ )
+ }
+ secondary={
+ props.highlight?.fields.text ? (
+
+ ) : (
+ result.text
+ )
+ }
/>
{result.kind && }
diff --git a/plugins/search-backend-module-elasticsearch/api-report.md b/plugins/search-backend-module-elasticsearch/api-report.md
index 10231f3a83..7ef026745f 100644
--- a/plugins/search-backend-module-elasticsearch/api-report.md
+++ b/plugins/search-backend-module-elasticsearch/api-report.md
@@ -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)
diff --git a/plugins/search-backend-module-elasticsearch/config.d.ts b/plugins/search-backend-module-elasticsearch/config.d.ts
index da403370cd..8f63e66365 100644
--- a/plugins/search-backend-module-elasticsearch/config.d.ts
+++ b/plugins/search-backend-module-elasticsearch/config.d.ts
@@ -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?: {
diff --git a/plugins/search-backend-module-elasticsearch/package.json b/plugins/search-backend-module-elasticsearch/package.json
index f53b20e377..de0288effe 100644
--- a/plugins/search-backend-module-elasticsearch/package.json
+++ b/plugins/search-backend-module-elasticsearch/package.json
@@ -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": {
diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts
index 467e1b4e77..134de5a9f6 100644
--- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts
+++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts
@@ -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: ``,
+ postTag: ``,
+ 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: ``,
+ postTag: ``,
+ 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: [''],
+ post_tags: [''],
+ },
+ 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 test result',
+ 'another fragment result',
+ ],
+ bar: ['more test 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: '',
+ postTag: '',
+ fields: {
+ foo: 'highlighted test result ... another fragment result',
+ bar: 'more test 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: [''],
+ post_tags: [''],
+ },
from: 0,
size: 25,
},
@@ -515,6 +648,13 @@ describe('ElasticSearchSearchEngine', () => {
filter: [],
},
},
+ highlight: {
+ fields: { '*': {} },
+ fragment_size: 1000,
+ number_of_fragments: 1,
+ pre_tags: [''],
+ post_tags: [''],
+ },
from: 0,
size: 25,
},
@@ -543,6 +683,13 @@ describe('ElasticSearchSearchEngine', () => {
filter: [],
},
},
+ highlight: {
+ fields: { '*': {} },
+ fragment_size: 1000,
+ number_of_fragments: 1,
+ pre_tags: [''],
+ post_tags: [''],
+ },
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: {
diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
index aec225a64b..5ec33a95e3 100644
--- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
+++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
@@ -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(
+ '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 {
- 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,
};
diff --git a/plugins/search-backend-module-elasticsearch/src/engines/index.ts b/plugins/search-backend-module-elasticsearch/src/engines/index.ts
index 19c24b37ff..c5628776ef 100644
--- a/plugins/search-backend-module-elasticsearch/src/engines/index.ts
+++ b/plugins/search-backend-module-elasticsearch/src/engines/index.ts
@@ -18,6 +18,9 @@ export { ElasticSearchSearchEngine } from './ElasticSearchSearchEngine';
export type {
ConcreteElasticSearchQuery,
ElasticSearchClientOptions,
+ ElasticSearchHighlightConfig,
+ ElasticSearchHighlightOptions,
+ ElasticSearchQueryTranslatorOptions,
} from './ElasticSearchSearchEngine';
export type {
ElasticSearchSearchEngineIndexer,
diff --git a/plugins/search-backend-module-elasticsearch/src/index.ts b/plugins/search-backend-module-elasticsearch/src/index.ts
index 223141be6b..744e12f8c8 100644
--- a/plugins/search-backend-module-elasticsearch/src/index.ts
+++ b/plugins/search-backend-module-elasticsearch/src/index.ts
@@ -23,6 +23,9 @@
export { ElasticSearchSearchEngine } from './engines';
export type {
ElasticSearchClientOptions,
+ ElasticSearchHighlightConfig,
+ ElasticSearchHighlightOptions,
+ ElasticSearchQueryTranslatorOptions,
ElasticSearchSearchEngineIndexer,
ElasticSearchSearchEngineIndexerOptions,
} from './engines';
diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md
index 61b8a124f7..0f379b3fc1 100644
--- a/plugins/search-backend-node/api-report.md
+++ b/plugins/search-backend-node/api-report.md
@@ -82,6 +82,10 @@ export class LunrSearchEngine implements SearchEngine {
// (undocumented)
getIndexer(type: string): Promise;
// (undocumented)
+ protected highlightPostTag: string;
+ // (undocumented)
+ protected highlightPreTag: string;
+ // (undocumented)
protected logger: Logger;
// (undocumented)
protected lunrIndices: Record;
diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json
index 433edcfbd0..b577f795a9 100644
--- a/plugins/search-backend-node/package.json
+++ b/plugins/search-backend-node/package.json
@@ -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": {
diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
index b0bd2bca8f..84b1a35165 100644
--- a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
+++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
@@ -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>',
+ });
+ });
+});
diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
index 09e8729ba8..656cd3e1eb 100644
--- a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
+++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
@@ -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 = {};
protected docStore: Record;
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];
+ }),
+ );
+}
diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngineIndexer.ts b/plugins/search-backend-node/src/engines/LunrSearchEngineIndexer.ts
index a798f7c81f..4da27be94b 100644
--- a/plugins/search-backend-node/src/engines/LunrSearchEngineIndexer.ts
+++ b/plugins/search-backend-node/src/engines/LunrSearchEngineIndexer.ts
@@ -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.
diff --git a/plugins/search-common/api-report.md b/plugins/search-common/api-report.md
index bd5a56c5cc..495d77ed26 100644
--- a/plugins/search-common/api-report.md
+++ b/plugins/search-common/api-report.md
@@ -55,9 +55,21 @@ export interface Result {
// (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 {
// (undocumented)
diff --git a/plugins/search-common/src/types.ts b/plugins/search-common/src/types.ts
index 4eedabeb71..220ac977e5 100644
--- a/plugins/search-common/src/types.ts
+++ b/plugins/search-common/src/types.ts
@@ -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 {
type: string;
document: TDocument;
+ highlight?: ResultHighlight;
}
/**
diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md
index e8d3afff82..9f61070854 100644
--- a/plugins/search-react/api-report.md
+++ b/plugins/search-react/api-report.md
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+///
+
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);
diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json
index 7579c3b9f8..e26b8350c1 100644
--- a/plugins/search-react/package.json
+++ b/plugins/search-react/package.json
@@ -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",
diff --git a/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.test.tsx b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.test.tsx
new file mode 100644
index 0000000000..3a78a66541
--- /dev/null
+++ b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.test.tsx
@@ -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(
+ ,
+ );
+
+ expect(
+ screen.getByText('highlighted').tagName.toLocaleLowerCase('en-US'),
+ ).toEqual('mark');
+ expect(screen.getByText('text').tagName.toLocaleLowerCase('en-US')).toEqual(
+ 'mark',
+ );
+ });
+});
diff --git a/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx
new file mode 100644
index 0000000000..a749f184fe
--- /dev/null
+++ b/plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx
@@ -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) ? (
+
+ {t.replace(new RegExp(`${preTag}|${postTag}`, 'g'), '')}
+
+ ) : (
+ t
+ ),
+ )}
+ >
+ );
+};
diff --git a/plugins/search-react/src/components/HighlightedSearchResultText/index.ts b/plugins/search-react/src/components/HighlightedSearchResultText/index.ts
new file mode 100644
index 0000000000..679a254827
--- /dev/null
+++ b/plugins/search-react/src/components/HighlightedSearchResultText/index.ts
@@ -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';
diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts
new file mode 100644
index 0000000000..679a254827
--- /dev/null
+++ b/plugins/search-react/src/components/index.ts
@@ -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';
diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts
index d24bd097de..9813661f3e 100644
--- a/plugins/search-react/src/index.ts
+++ b/plugins/search-react/src/index.ts
@@ -22,6 +22,7 @@
export { searchApiRef, MockSearchApi } from './api';
export type { SearchApi } from './api';
+export * from './components';
export {
SearchContextProvider,
useSearch,
diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md
index 31d357f10c..05dcdd89b3 100644
--- a/plugins/search/api-report.md
+++ b/plugins/search/api-report.md
@@ -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;
diff --git a/plugins/search/package.json b/plugins/search/package.json
index ee8c84fd58..23d7ce79c6 100644
--- a/plugins/search/package.json
+++ b/plugins/search/package.json
@@ -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": {
diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx
index 91143bb292..fb56d1ecdb 100644
--- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx
+++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.stories.tsx
@@ -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 (
+ ',
+ postTag: '',
+ fields: { text: 'some text from the search result' },
+ }}
+ />
+ );
+};
+
+export const WithCustomHighlightedResults = () => {
+ const customTheme = {
+ ...lightTheme,
+ overrides: {
+ ...lightTheme.overrides,
+ BackstageHighlightedSearchResultText: {
+ highlight: {
+ color: 'inherit',
+ backgroundColor: 'inherit',
+ fontWeight: 'bold',
+ textDecoration: 'underline',
+ },
+ },
+ },
+ };
+
+ return (
+
+
+ ',
+ postTag: '',
+ fields: { text: 'some text from the search result' },
+ }}
+ />
+
+
+ );
+};
diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx
index 68f00f9301..26d081b3b6 100644
--- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx
+++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.test.tsx
@@ -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 }) => {text};
-});
-
describe('DefaultResultListItem', () => {
const result = {
title: 'title',
diff --git a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx
index 46045f91ee..5ce2d0744a 100644
--- a/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx
+++ b/plugins/search/src/components/DefaultResultListItem/DefaultResultListItem.tsx
@@ -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 && {icon}}
+ ) : (
+ result.title
+ )
+ }
secondary={
-
+
+ {highlight?.fields.text ? (
+
+ ) : (
+ result.text
+ )}
+
}
/>
{secondaryAction && {secondaryAction}}
diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx
index 55fd0cb977..b3df52d42e 100644
--- a/plugins/search/src/components/SearchModal/SearchModal.tsx
+++ b/plugins/search/src/components/SearchModal/SearchModal.tsx
@@ -137,7 +137,7 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
{({ results }) => (
- {results.map(({ document }) => (
+ {results.map(({ document, highlight }) => (
{
))}
diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md
index 131a3a062a..a8204bc9af 100644
--- a/plugins/techdocs/api-report.md
+++ b/plugins/techdocs/api-report.md
@@ -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;
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index ea899088dd..97a763b26e 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -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": {
diff --git a/plugins/techdocs/src/search/components/TechDocsSearch.tsx b/plugins/techdocs/src/search/components/TechDocsSearch.tsx
index f11fa38948..509c991353 100644
--- a/plugins/techdocs/src/search/components/TechDocsSearch.tsx
+++ b/plugins/techdocs/src/search/components/TechDocsSearch.tsx
@@ -143,13 +143,14 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
noOptionsText="No results found"
value={null}
options={options}
- renderOption={({ document }) => (
+ renderOption={({ document, highlight }) => (
)}
loading={loading}
diff --git a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx
index db5751654d..6181e05235 100644
--- a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx
+++ b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx
@@ -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 = () => (
-
- }
- />
- );
+ const TextItem = () => {
+ const resultTitle = highlight?.fields.title ? (
+
+ ) : (
+ result.title
+ );
+
+ const entityTitle = highlight?.fields.entityTitle ? (
+
+ ) : (
+ result.entityTitle
+ );
+
+ const resultName = highlight?.fields.name ? (
+
+ ) : (
+ result.name
+ );
+
+ return (
+
+ {resultTitle} | {entityTitle ?? resultName} docs
+ >
+ )
+ }
+ secondary={
+
+ {highlight?.fields.text ? (
+
+ ) : (
+ result.text
+ )}
+
+ }
+ />
+ );
+ };
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
asLink ? {children} : <>{children}>;