{props.icon && {props.icon}}
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 c114406d99..7892b731ed 100644
--- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts
+++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.test.ts
@@ -523,6 +523,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
+ rank: i + 1,
})),
),
});
@@ -567,6 +568,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
+ rank: i + 1,
})),
),
nextPageCursor: 'MQ==',
@@ -614,6 +616,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
+ rank: i + 1,
}))
.slice(25),
),
@@ -667,6 +670,7 @@ describe('ElasticSearchSearchEngine', () => {
.map((_, i) => ({
type: 'mytype',
document: { value: `${i}` },
+ rank: i + 1,
highlight: {
preTag: '',
postTag: '',
diff --git a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
index d11d8a1f90..31112e3446 100644
--- a/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
+++ b/plugins/search-backend-module-elasticsearch/src/engines/ElasticSearchSearchEngine.ts
@@ -334,27 +334,30 @@ export class ElasticSearchSearchEngine implements SearchEngine {
: undefined;
return {
- 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),
- ]),
- ),
+ results: result.body.hits.hits.map(
+ (d: ElasticSearchResult, index: number) => {
+ const resultItem: IndexableResult = {
+ type: this.getTypeFromIndex(d._index),
+ document: d._source,
+ rank: pageSize * page + index + 1,
};
- }
- return resultItem;
- }),
+ 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-pg/src/PgSearchEngine/PgSearchEngine.test.ts b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
index cc89f28c80..3ed002567e 100644
--- a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
+++ b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.test.ts
@@ -174,6 +174,7 @@ describe('PgSearchEngine', () => {
location: 'location-1',
},
type: 'my-type',
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -215,6 +216,7 @@ describe('PgSearchEngine', () => {
location: `location-${i}`,
},
type: 'my-type',
+ rank: i + 1,
})),
nextPageCursor: 'MQ==',
});
@@ -257,6 +259,7 @@ describe('PgSearchEngine', () => {
location: `location-${i}`,
},
type: 'my-type',
+ rank: i + 1,
}))
.slice(25),
previousPageCursor: 'MA==',
diff --git a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
index 445d1bb5c1..bf98c58de9 100644
--- a/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
+++ b/plugins/search-backend-module-pg/src/PgSearchEngine/PgSearchEngine.ts
@@ -18,6 +18,7 @@ import { SearchEngine } from '@backstage/plugin-search-backend-node';
import {
SearchQuery,
IndexableResultSet,
+ IndexableResult,
} from '@backstage/plugin-search-common';
import { PgSearchEngineIndexer } from './PgSearchEngineIndexer';
import {
@@ -104,10 +105,13 @@ export class PgSearchEngine implements SearchEngine {
? encodePageCursor({ page: page - 1 })
: undefined;
- const results = pageRows.map(({ type, document }) => ({
- type,
- document,
- }));
+ const results = pageRows.map(
+ ({ type, document }, index): IndexableResult => ({
+ type,
+ document,
+ rank: page * pageSize + index + 1,
+ }),
+ );
return { results, nextPageCursor, previousPageCursor };
}
diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
index 84b1a35165..098d1e8b16 100644
--- a/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
+++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.test.ts
@@ -431,6 +431,7 @@ describe('LunrSearchEngine', () => {
location: 'test/location',
},
type: 'test-index',
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -469,6 +470,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -521,6 +523,7 @@ describe('LunrSearchEngine', () => {
location: `${highlightTags.pre}test/location${highlightTags.post}`,
},
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -559,6 +562,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -598,6 +602,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -637,6 +642,7 @@ describe('LunrSearchEngine', () => {
text: 'Hello World.',
location: 'test/location',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -676,6 +682,7 @@ describe('LunrSearchEngine', () => {
text: 'Searching',
location: 'test/location',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -722,6 +729,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test/location2',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -777,6 +785,7 @@ describe('LunrSearchEngine', () => {
location: 'test/location2',
extraField: 'testExtraField',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -823,6 +832,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
location: 'test:location2',
},
+ rank: 1,
},
],
nextPageCursor: undefined,
@@ -886,6 +896,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
title: 'testTitle',
},
+ rank: 1,
},
{
document: {
@@ -893,6 +904,7 @@ describe('LunrSearchEngine', () => {
text: 'testText',
title: 'testTitle',
},
+ rank: 2,
},
],
nextPageCursor: undefined,
@@ -931,6 +943,7 @@ describe('LunrSearchEngine', () => {
location: `test/location/${i}`,
},
type: 'test-index',
+ rank: i + 1,
})),
nextPageCursor: 'MQ==',
previousPageCursor: undefined,
@@ -968,6 +981,7 @@ describe('LunrSearchEngine', () => {
location: `test/location/${i}`,
},
type: 'test-index',
+ rank: i + 1,
}))
.slice(25),
nextPageCursor: undefined,
diff --git a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
index 4a5f4cdd18..03cb421653 100644
--- a/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
+++ b/plugins/search-backend-node/src/engines/LunrSearchEngine.ts
@@ -207,9 +207,10 @@ export class LunrSearchEngine implements SearchEngine {
// Translate results into IndexableResultSet
const realResultSet: IndexableResultSet = {
- results: results.slice(offset, offset + pageSize).map(d => ({
+ results: results.slice(offset, offset + pageSize).map((d, index) => ({
type: d.type,
document: this.docStore[d.result.ref],
+ rank: page * pageSize + index + 1,
highlight: {
preTag: this.highlightPreTag,
postTag: this.highlightPostTag,
diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts
index 256608fd82..b2355a7239 100644
--- a/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts
+++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.test.ts
@@ -44,6 +44,7 @@ describe('AuthorizedSearchEngine', () => {
.fill(0)
.map((_, index) => ({
type,
+ rank: index + 1,
document: {
title: `${type}_doc_${index}`,
authorization: withAuthorization
@@ -261,9 +262,11 @@ describe('AuthorizedSearchEngine', () => {
}),
);
+ const expectedResult = { ...usersWithAuth[8], rank: 1 };
+
await expect(
authorizedSearchEngine.query({ term: '' }, options),
- ).resolves.toEqual({ results: [usersWithAuth[8]] });
+ ).resolves.toEqual({ results: [expectedResult] });
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users'] },
@@ -275,6 +278,7 @@ describe('AuthorizedSearchEngine', () => {
const searchResults = [
{
type: 'templates',
+ rank: 1,
document: {
title: `doc_0_a`,
authorization: { resourceRef: `template_doc_0` },
@@ -282,6 +286,7 @@ describe('AuthorizedSearchEngine', () => {
},
{
type: 'templates',
+ rank: 2,
document: {
title: `doc_0_b`,
authorization: { resourceRef: `template_doc_0` },
@@ -419,7 +424,9 @@ describe('AuthorizedSearchEngine', () => {
{ token: 'token' },
);
- const expectedResult = allDocuments.slice(0, 25);
+ const expectedResult = allDocuments
+ .slice(0, 25)
+ .map((r, i) => ({ ...r, rank: i + 1 }));
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
@@ -510,7 +517,8 @@ describe('AuthorizedSearchEngine', () => {
const expectedResult = allDocuments
.filter(d => d.type !== typeServices)
- .slice(0, 25);
+ .slice(0, 25)
+ .map((d, i) => ({ ...d, rank: i + 1 }));
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
@@ -587,8 +595,12 @@ describe('AuthorizedSearchEngine', () => {
{ token: 'token' },
);
+ const expectedResults = servicesWithAuth
+ .slice(5)
+ .map((r, i) => ({ ...r, rank: 25 + i + 1 }));
+
expect(result).toEqual({
- results: servicesWithAuth.slice(5),
+ results: expectedResults,
previousPageCursor: encodePageCursor({ page: 0 }),
});
});
diff --git a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts
index a74cdf8eef..ade43280be 100644
--- a/plugins/search-backend/src/service/AuthorizedSearchEngine.ts
+++ b/plugins/search-backend/src/service/AuthorizedSearchEngine.ts
@@ -189,10 +189,15 @@ export class AuthorizedSearchEngine implements SearchEngine {
);
return {
- results: filteredResults.slice(
- page * this.pageSize,
- (page + 1) * this.pageSize,
- ),
+ results: filteredResults
+ .slice(page * this.pageSize, (page + 1) * this.pageSize)
+ .map((result, index) => {
+ // Overwrite any/all rank entries to avoid leaking knowledge of filtered results.
+ return {
+ ...result,
+ rank: page * this.pageSize + index + 1,
+ };
+ }),
previousPageCursor:
page === 0 ? undefined : encodePageCursor({ page: page - 1 }),
nextPageCursor:
diff --git a/plugins/search-common/api-report.md b/plugins/search-common/api-report.md
index bb049742f7..ea9fc2d26e 100644
--- a/plugins/search-common/api-report.md
+++ b/plugins/search-common/api-report.md
@@ -52,11 +52,9 @@ export type QueryTranslator = (query: SearchQuery) => unknown;
// @public (undocumented)
export interface Result {
- // (undocumented)
document: TDocument;
- // (undocumented)
highlight?: ResultHighlight;
- // (undocumented)
+ rank?: number;
type: string;
}
diff --git a/plugins/search-common/src/types.ts b/plugins/search-common/src/types.ts
index fdcc06287c..4b4c25d423 100644
--- a/plugins/search-common/src/types.ts
+++ b/plugins/search-common/src/types.ts
@@ -56,9 +56,27 @@ export interface ResultHighlight {
* @public
*/
export interface Result {
+ /**
+ * The "type" of the given document. See: {@link DocumentCollatorFactory."type"}
+ */
type: string;
+
+ /**
+ * The raw value of the document, as indexed.
+ */
document: TDocument;
+
+ /**
+ * Optional result highlight. Useful for improving the search result
+ * display/experience.
+ */
highlight?: ResultHighlight;
+
+ /**
+ * Optional result rank, where 1 is the first/top result returned. Useful for
+ * understanding search effectiveness in analytics.
+ */
+ rank?: number;
}
/**
diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md
index f9bb993fd6..e4afee45ca 100644
--- a/plugins/search-react/api-report.md
+++ b/plugins/search-react/api-report.md
@@ -38,6 +38,7 @@ export type DefaultResultListItemProps = {
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight;
+ rank?: number;
lineClamp?: number;
};
diff --git a/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx
index ff6f3d7fe1..f4260525d6 100644
--- a/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx
+++ b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx
@@ -15,7 +15,7 @@
*/
import React, { ReactNode } from 'react';
-import { AnalyticsContext } from '@backstage/core-plugin-api';
+import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import {
ResultHighlight,
SearchDocument,
@@ -40,6 +40,7 @@ export type DefaultResultListItemProps = {
secondaryAction?: ReactNode;
result: SearchDocument;
highlight?: ResultHighlight;
+ rank?: number;
lineClamp?: number;
};
@@ -51,12 +52,21 @@ export type DefaultResultListItemProps = {
export const DefaultResultListItemComponent = ({
result,
highlight,
+ rank,
icon,
secondaryAction,
lineClamp = 5,
}: DefaultResultListItemProps) => {
+ const analytics = useAnalytics();
+ const handleClick = () => {
+ analytics.captureEvent('discover', result.title, {
+ attributes: { to: result.location },
+ value: rank,
+ });
+ };
+
return (
-
+
{icon && {icon}}
{
+ analytics.captureEvent('discover', result.title, {
+ attributes: { to: result.location },
+ value: rank,
+ });
+ };
+
const TextItem = () => {
const resultTitle = highlight?.fields.title ? (
) =>
- asLink ? {children} : <>{children}>;
+ asLink ? (
+
+ {children}
+
+ ) : (
+ <>{children}>
+ );
const ListItemWrapper = ({ children }: PropsWithChildren<{}>) =>
asListItem ? (