From 3a74e203a86c05d9475f5598b4a6de608d67aa26 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Wed, 20 Apr 2022 00:05:52 -0400 Subject: [PATCH] feat(search): highlight search result terms Signed-off-by: Phil Kuang --- .changeset/thick-tools-dream.md | 40 +++++ .changeset/tricky-laws-thank.md | 8 + .changeset/warm-suns-wonder.md | 7 + docs/features/search/getting-started.md | 3 + docs/features/search/how-to-guides.md | 23 +++ .../app/src/components/search/SearchModal.tsx | 5 +- .../app/src/components/search/SearchPage.tsx | 5 +- .../app/src/components/search/SearchPage.tsx | 5 +- plugins/catalog/api-report.md | 3 + plugins/catalog/package.json | 1 + .../CatalogSearchResultListItem.tsx | 31 +++- .../api-report.md | 27 ++- .../config.d.ts | 18 ++ .../package.json | 3 +- .../engines/ElasticSearchSearchEngine.test.ts | 163 +++++++++++++++++- .../src/engines/ElasticSearchSearchEngine.ts | 107 ++++++++++-- .../src/engines/index.ts | 3 + .../src/index.ts | 3 + plugins/search-backend-node/api-report.md | 4 + plugins/search-backend-node/package.json | 1 + .../src/engines/LunrSearchEngine.test.ts | 89 ++++++++++ .../src/engines/LunrSearchEngine.ts | 72 +++++++- .../src/engines/LunrSearchEngineIndexer.ts | 1 + plugins/search-common/api-report.md | 12 ++ plugins/search-common/src/types.ts | 25 +++ plugins/search-react/api-report.md | 16 ++ plugins/search-react/package.json | 3 +- .../HighlightedSearchResultText.test.tsx | 39 +++++ .../HighlightedSearchResultText.tsx | 62 +++++++ .../HighlightedSearchResultText/index.ts | 17 ++ plugins/search-react/src/components/index.ts | 17 ++ plugins/search-react/src/index.ts | 1 + plugins/search/api-report.md | 3 + plugins/search/package.json | 1 - .../DefaultResultListItem.stories.tsx | 47 +++++ .../DefaultResultListItem.test.tsx | 5 - .../DefaultResultListItem.tsx | 45 ++++- .../components/SearchModal/SearchModal.tsx | 3 +- plugins/techdocs/api-report.md | 2 + plugins/techdocs/package.json | 2 +- .../src/search/components/TechDocsSearch.tsx | 3 +- .../TechDocsSearchResultListItem.tsx | 91 +++++++--- 42 files changed, 947 insertions(+), 69 deletions(-) create mode 100644 .changeset/thick-tools-dream.md create mode 100644 .changeset/tricky-laws-thank.md create mode 100644 .changeset/warm-suns-wonder.md create mode 100644 plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.test.tsx create mode 100644 plugins/search-react/src/components/HighlightedSearchResultText/HighlightedSearchResultText.tsx create mode 100644 plugins/search-react/src/components/HighlightedSearchResultText/index.ts create mode 100644 plugins/search-react/src/components/index.ts 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 1557a29b53..04b3e006c2 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 = ( ); } @@ -267,6 +269,7 @@ an example: ); // ... diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index c5f53c905f..a4139a35ce 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -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 `` 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: ``, + 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 = ``; } 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 49afafea64..87598a86f1 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 dc3392a2ea..b6e51f60d4 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};