Merge pull request #15855 from backstage/search/introduce-result-item-extensions

[Search] Introduce Result Item Extensions 
This commit is contained in:
Camila Belo
2023-01-28 10:34:05 +01:00
committed by GitHub
42 changed files with 1374 additions and 433 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': minor
---
Update `SearchModal` component to use `SearchResult` extensions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': minor
---
Update `SearchPage` template to use `SearchResult` extensions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
The `CatalogSearchResultListItem` component is now a search result extension. This means that when rendered as a child of components that render search extensions, the `result`, `rank`, and `highlight` properties are optional. See the [documentation](https://backstage.io/docs/features/search/how-to-guides#how-to-render-search-results-using-extensions) for more details.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-explore': minor
---
The `ToolSearchResultListItem` component is now a search result extension. This means that when rendered as a child of components that render search extensions, the `result`, `rank`, and `highlight` properties are optional. See the [documentation](https://backstage.io/docs/features/search/how-to-guides#how-to-render-search-results-using-extensions) for more details.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-search-react': minor
---
- Create the search results extensions, for more details see the documentation [here](https://backstage.io/docs/features/search/how-to-guides#how-to-render-search-results-using-extensions);
- Update the `SearchResult`, `SearchResultList` and `SearchResultGroup` components to use extensions and default their props to optionally accept a query, when the query is not passed, the component tries to get it from the search context.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': minor
---
The `TechDocsSearchResultListItem` component is now a search result extension. This means that when rendered as a child of components that render search extensions, the `result`, `rank`, and `highlight` properties are optional. See the [documentation](https://backstage.io/docs/features/search/how-to-guides#how-to-render-search-results-using-extensions) for more details.
+153
View File
@@ -210,3 +210,156 @@ const highlightOverride = {
[obj-mode]: https://nodejs.org/dist/latest-v16.x/docs/api/stream.html#stream_object_mode
[read-stream]: https://nodejs.org/dist/latest-v16.x/docs/api/stream.html#readable-streams
[async-gen]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_async_generators
## How to render search results using extensions
Extensions for search results let you customize components used to render search result items, It is possible to provide your own search result item extensions or use the ones provided by plugin packages:
### 1. Providing an extension in your plugin package
Using the example below, you can provide an extension to be used as a default result item:
```tsx
// plugins/your-plugin/src/plugin.ts
import { createPlugin } from '@backstage/core-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react';
const plugin = createPlugin({ id: 'YOUR_PLUGIN_ID' });
export const YourSearchResultListItemExtension = plugin.provide(
createSearchResultListItemExtension({
name: 'YourSearchResultListItem',
component: () =>
import('./components').then(m => m.YourSearchResultListItem),
}),
);
```
Additionally, you can define a predicate function that receives a result and returns whether your extension should be used to render it or not:
```tsx
// plugins/your-plugin/src/plugin.ts
import { createPlugin } from '@backstage/core-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react';
const plugin = createPlugin({ id: 'YOUR_PLUGIN_ID' });
export const YourSearchResultListItemExtension = plugin.provide(
createSearchResultListItemExtension({
name: 'YourSearchResultListItem',
component: () =>
import('./components').then(m => m.YourSearchResultListItem),
// Only results matching your type will be rendered by this extension
predicate: result => result.type === 'YOUR_RESULT_TYPE',
}),
);
```
Remember to export your new extension:
```tsx
// plugins/your-plugin/src/index.ts
export { YourSearchResultListItem } from './plugin.ts';
```
For more details, see the [createSearchResultListItemExtension](https://backstage.io/docs/reference/plugin-search-react.createsearchresultlistitemextension) API reference.
### 2. Using an extension in your Backstage app
Now that you know how a search result item is provided, let's finally see how they can be used, for example, to compose a page in your application:
```tsx
// packages/app/src/components/searchPage.tsx
import React from 'react';
import { Grid, Paper } from '@material-ui/core';
import BuildIcon from '@material-ui/icons/Build';
import {
Page,
Header,
Content,
DocsIcon,
CatalogIcon,
} from '@backstage/core-components';
import { SearchBar, SearchResult } from '@backstage/plugin-search-react';
// Your search result item extension
import { YourSearchResultListItem } from '@backstage/your-plugin';
// Extensions provided by other plugin developers
import { ToolSearchResultListItem } from '@backstage/plugin-explore';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { CatalogSearchResultListItem } from '@internal/plugin-catalog-customized';
// This example omits other components, like filter and pagination
const SearchPage = () => (
<Page themeId="home">
<Header title="Search" />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<Paper>
<SearchBar />
</Paper>
</Grid>
<Grid item xs={12}>
<SearchResult>
<YourSearchResultListItem />
<CatalogSearchResultListItem icon={<CatalogIcon />} />
<TechDocsSearchResultListItem icon={<DocsIcon />} />
<ToolSearchResultListItem icon={<BuildIcon />} />
</SearchResult>
</Grid>
</Grid>
</Content>
</Page>
);
export const searchPage = <SearchPage />;
```
> **Important**: A default result item extension should be placed as the last child, so it can be used only when no other extensions match the result being rendered. If a non-default extension is specified, the `DefaultResultListItem` component will be used.
As another example, here's a search modal that renders results with extensions:
```tsx
// packages/app/src/components/searchModal.tsx
import React from 'react';
import { DialogContent, DialogTitle, Paper } from '@material-ui/core';
import BuildIcon from '@material-ui/icons/Build';
import { DocsIcon, CatalogIcon } from '@backstage/core-components';
import { SearchBar, SearchResult } from '@backstage/plugin-search-react';
// Your search result item extension
import { YourSearchResultListItem } from '@backstage/your-plugin';
// Extensions provided by other plugin developers
import { ToolSearchResultListItem } from '@backstage/plugin-explore';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { CatalogSearchResultListItem } from '@internal/plugin-catalog-customized';
export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => (
<>
<DialogTitle>
<Paper>
<SearchBar />
</Paper>
</DialogTitle>
<DialogContent>
<SearchResult onClick={toggleModal}>
<CatalogSearchResultListItem icon={<CatalogIcon />} />
<TechDocsSearchResultListItem icon={<DocsIcon />} />
<ToolSearchResultListItem icon={<BuildIcon />} />
{/* As a "default" extension, it does not define a predicate function,
so it must be the last child to render results that do not match the above extensions */}
<YourSearchResultListItem />
</SearchResult>
</DialogContent>
</>
);
```
There are other more specific search results layout components that also accept result item extensions, check their documentation: [SearchResultList](https://backstage.io/storybook/?path=/story/plugins-search-searchresultlist--with-result-item-extensions) and [SearchResultGroup](https://backstage.io/storybook/?path=/story/plugins-search-searchresultgroup--with-result-item-extensions).
@@ -21,7 +21,6 @@ import {
DialogContent,
DialogTitle,
Grid,
List,
makeStyles,
Paper,
useTheme,
@@ -44,9 +43,8 @@ import {
import { ToolSearchResultListItem } from '@backstage/plugin-explore';
import { searchPlugin, SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchFilter,
SearchBar,
SearchFilter,
SearchResult,
SearchResultPager,
useSearch,
@@ -93,7 +91,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
searchBarRef?.current?.focus();
});
const handleSearchResulClick = useCallback(() => {
const handleSearchResultClick = useCallback(() => {
toggleModal();
setTimeout(focusContent, transitions.duration.leavingScreen);
}, [toggleModal, focusContent, transitions]);
@@ -101,11 +99,11 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
const handleSearchBarKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
handleSearchResultClick();
navigate(searchPagePath);
toggleModal();
}
},
[navigate, toggleModal, searchPagePath],
[navigate, searchPagePath, handleSearchResultClick],
);
return (
@@ -189,7 +187,7 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
alignItems="center"
>
<Grid item>
<Link to={searchPagePath} onClick={handleSearchResulClick}>
<Link to={searchPagePath} onClick={handleSearchResultClick}>
<Typography
component="span"
className={classes.viewResultsLink}
@@ -202,69 +200,13 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
</Grid>
</Grid>
<Grid item xs>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight, rank }) => {
let resultItem;
switch (type) {
case 'software-catalog':
resultItem = (
<CatalogSearchResultListItem
icon={<CatalogIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
case 'techdocs':
resultItem = (
<TechDocsSearchResultListItem
icon={<DocsIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
case 'tools':
resultItem = (
<ToolSearchResultListItem
icon={<BuildIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
break;
default:
resultItem = (
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
return (
<div
role="button"
tabIndex={0}
key={`${document.location}-btn`}
onClick={handleSearchResulClick}
onKeyDown={handleSearchResulClick}
>
{resultItem}
</div>
);
})}
</List>
)}
<SearchResult
onClick={handleSearchResultClick}
onKeyDown={handleSearchResultClick}
>
<CatalogSearchResultListItem icon={<CatalogIcon />} />
<TechDocsSearchResultListItem icon={<DocsIcon />} />
<ToolSearchResultListItem icon={<BuildIcon />} />
</SearchResult>
</Grid>
</Grid>
@@ -30,16 +30,15 @@ import {
} from '@backstage/plugin-catalog-react';
import { SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
SearchResult,
SearchPagination,
SearchResult,
SearchResultPager,
useSearch,
} from '@backstage/plugin-search-react';
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
import { Grid, makeStyles, Paper, Theme } from '@material-ui/core';
import React from 'react';
import { ToolSearchResultListItem } from '@backstage/plugin-explore';
import BuildIcon from '@material-ui/icons/Build';
@@ -133,53 +132,9 @@ const SearchPage = () => {
<Grid item xs>
<SearchPagination />
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
icon={<CatalogIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
icon={<DocsIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'tools':
return (
<ToolSearchResultListItem
icon={<BuildIcon />}
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
})}
</List>
)}
<CatalogSearchResultListItem icon={<CatalogIcon />} />
<TechDocsSearchResultListItem icon={<DocsIcon />} />
<ToolSearchResultListItem icon={<BuildIcon />} />
</SearchResult>
<SearchResultPager />
</Grid>
@@ -1,5 +1,5 @@
import React from 'react';
import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core';
import { makeStyles, Theme, Grid, Paper } from '@material-ui/core';
import { CatalogSearchResultListItem } from '@backstage/plugin-catalog';
import {
@@ -10,7 +10,6 @@ import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
import { SearchType } from '@backstage/plugin-search';
import {
DefaultResultListItem,
SearchBar,
SearchFilter,
SearchResult,
@@ -112,41 +111,8 @@ const SearchPage = () => {
<Grid item xs={9}>
<SearchPagination />
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document, highlight, rank }) => {
switch (type) {
case 'software-catalog':
return (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
case 'techdocs':
return (
<TechDocsSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
);
}
})}
</List>
)}
<CatalogSearchResultListItem icon={<CatalogIcon />} />
<TechDocsSearchResultListItem icon={<DocsIcon />} />
</SearchResult>
</Grid>
</Grid>
+3 -3
View File
@@ -107,9 +107,9 @@ export const catalogPlugin: BackstagePlugin<
>;
// @public (undocumented)
export function CatalogSearchResultListItem(
export const CatalogSearchResultListItem: (
props: CatalogSearchResultListItemProps,
): JSX.Element;
) => JSX.Element | null;
// @public
export interface CatalogSearchResultListItemProps {
@@ -120,7 +120,7 @@ export interface CatalogSearchResultListItemProps {
// (undocumented)
rank?: number;
// (undocumented)
result: IndexableDocument;
result?: IndexableDocument;
}
// @public (undocumented)
@@ -25,7 +25,6 @@ import {
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
IndexableDocument,
ResultHighlight,
@@ -50,7 +49,7 @@ const useStyles = makeStyles({
*/
export interface CatalogSearchResultListItemProps {
icon?: ReactNode;
result: IndexableDocument;
result?: IndexableDocument;
highlight?: ResultHighlight;
rank?: number;
}
@@ -63,13 +62,8 @@ export function CatalogSearchResultListItem(
const highlight = props.highlight as ResultHighlight;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: props.rank,
});
};
if (!result) return null;
return (
<>
@@ -80,7 +74,7 @@ export function CatalogSearchResultListItem(
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
<Link noTrack to={result.location} onClick={handleClick}>
<Link noTrack to={result.location}>
{highlight?.fields.title ? (
<HighlightedSearchResultText
text={highlight.fields.title}
+2 -1
View File
@@ -29,7 +29,6 @@ export type {
} from './components/AboutCard';
export { AboutContent, AboutField } from './components/AboutCard';
export * from './components/CatalogKindHeader';
export * from './components/CatalogSearchResultListItem';
export * from './components/CatalogTable';
export * from './components/EntityLayout';
export * from './components/EntityOrphanWarning';
@@ -53,6 +52,7 @@ export {
EntityLinksCard,
EntityLabelsCard,
RelatedEntitiesCard,
CatalogSearchResultListItem,
} from './plugin';
export type { DependencyOfComponentsCardProps } from './components/DependencyOfComponentsCard';
@@ -72,3 +72,4 @@ export type { HasResourcesCardProps } from './components/HasResourcesCard';
export type { HasSubcomponentsCardProps } from './components/HasSubcomponentsCard';
export type { HasSystemsCardProps } from './components/HasSystemsCard';
export type { RelatedEntitiesCardProps } from './components/RelatedEntitiesCard';
export type { CatalogSearchResultListItemProps } from './components/CatalogSearchResultListItem';
+16
View File
@@ -31,6 +31,7 @@ import {
fetchApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react';
import { DefaultStarredEntitiesApi } from './apis';
import { AboutCardProps } from './components/AboutCard';
import { DefaultCatalogPageProps } from './components/CatalogPage';
@@ -42,6 +43,7 @@ import { HasResourcesCardProps } from './components/HasResourcesCard';
import { HasSubcomponentsCardProps } from './components/HasSubcomponentsCard';
import { HasSystemsCardProps } from './components/HasSystemsCard';
import { RelatedEntitiesCardProps } from './components/RelatedEntitiesCard';
import { CatalogSearchResultListItemProps } from './components/CatalogSearchResultListItem';
import { rootRouteRef } from './routes';
import { CatalogInputPluginOptions, CatalogPluginOptions } from './options';
@@ -249,3 +251,17 @@ export const RelatedEntitiesCard: <T extends Entity>(
},
}),
);
/** @public */
export const CatalogSearchResultListItem: (
props: CatalogSearchResultListItemProps,
) => JSX.Element | null = catalogPlugin.provide(
createSearchResultListItemExtension({
name: 'CatalogSearchResultListItem',
component: () =>
import('./components/CatalogSearchResultListItem').then(
m => m.CatalogSearchResultListItem,
),
predicate: result => result.type === 'software-catalog',
}),
);
+3 -3
View File
@@ -125,9 +125,9 @@ export const ToolExplorerContent: (props: {
}) => JSX.Element;
// @public (undocumented)
export function ToolSearchResultListItem(
export const ToolSearchResultListItem: (
props: ToolSearchResultListItemProps,
): JSX.Element;
) => JSX.Element | null;
// @public
export interface ToolSearchResultListItemProps {
@@ -138,6 +138,6 @@ export interface ToolSearchResultListItemProps {
// (undocumented)
rank?: number;
// (undocumented)
result: IndexableDocument;
result?: IndexableDocument;
}
```
@@ -25,7 +25,6 @@ import {
makeStyles,
} from '@material-ui/core';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
IndexableDocument,
ResultHighlight,
@@ -50,23 +49,18 @@ const useStyles = makeStyles({
*/
export interface ToolSearchResultListItemProps {
icon?: ReactNode;
result: IndexableDocument;
result?: IndexableDocument;
highlight?: ResultHighlight;
rank?: number;
}
/** @public */
/** @public */
export function ToolSearchResultListItem(props: ToolSearchResultListItemProps) {
const result = props.result as any;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: props.rank,
});
};
if (!result) return null;
return (
<>
@@ -77,7 +71,7 @@ export function ToolSearchResultListItem(props: ToolSearchResultListItemProps) {
className={classes.itemText}
primaryTypographyProps={{ variant: 'h6' }}
primary={
<Link noTrack to={result.location} onClick={handleClick}>
<Link noTrack to={result.location}>
{props.highlight?.fields.title ? (
<HighlightedSearchResultText
text={props.highlight.fields.title}
-1
View File
@@ -16,4 +16,3 @@
export * from './DomainCard';
export * from './ExploreLayout';
export * from './ToolSearchResultListItem';
+7 -1
View File
@@ -23,5 +23,11 @@
export * from './api';
export * from './components';
export * from './extensions';
export { explorePlugin, explorePlugin as plugin } from './plugin';
export {
ToolSearchResultListItem,
explorePlugin,
explorePlugin as plugin,
} from './plugin';
export * from './routes';
export type { ToolSearchResultListItemProps } from './components/ToolSearchResultListItem';
+16
View File
@@ -22,7 +22,9 @@ import {
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react';
import { ExploreClient, exploreApiRef } from './api';
import { ToolSearchResultListItemProps } from './components/ToolSearchResultListItem';
// import { exampleTools } from './util/examples';
/** @public */
@@ -65,3 +67,17 @@ export const explorePlugin = createPlugin({
catalogEntity: catalogEntityRouteRef,
},
});
/** @public */
export const ToolSearchResultListItem: (
props: ToolSearchResultListItemProps,
) => JSX.Element | null = explorePlugin.provide(
createSearchResultListItemExtension({
name: 'ToolSearchResultListItem',
component: () =>
import('./components/ToolSearchResultListItem').then(
m => m.ToolSearchResultListItem,
),
predicate: result => result.type === 'tools',
}),
);
+58 -24
View File
@@ -8,6 +8,7 @@
import { ApiRef } from '@backstage/core-plugin-api';
import { AsyncState } from 'react-use/lib/useAsync';
import { AutocompleteProps } from '@material-ui/lab';
import { Extension } from '@backstage/core-plugin-api';
import { ForwardRefExoticComponent } from 'react';
import { InputBaseProps } from '@material-ui/core';
import { JsonObject } from '@backstage/types';
@@ -34,6 +35,13 @@ export const AutocompleteFilter: (
// @public (undocumented)
export const CheckboxFilter: (props: SearchFilterComponentProps) => JSX.Element;
// @public
export const createSearchResultListItemExtension: <
Component extends (props: any) => JSX.Element | null,
>(
options: SearchResultListItemExtensionOptions<Component>,
) => Extension<Component>;
// @public (undocumented)
export const DefaultResultListItem: (
props: DefaultResultListItemProps,
@@ -43,7 +51,7 @@ export const DefaultResultListItem: (
export type DefaultResultListItemProps = {
icon?: ReactNode;
secondaryAction?: ReactNode;
result: SearchDocument;
result?: SearchDocument;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
@@ -280,7 +288,10 @@ export const SearchResultContext: (
// @public
export type SearchResultContextProps = {
children: (state: AsyncState<SearchResultSet>) => JSX.Element | null;
children: (
state: AsyncState<SearchResultSet>,
query: Partial<SearchQuery>,
) => JSX.Element | null;
};
// @public
@@ -313,6 +324,8 @@ export function SearchResultGroupLayout<FilterOption>(
// @public
export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
error?: Error;
loading?: boolean;
icon: JSX.Element;
title: ReactNode;
titleProps?: Partial<TypographyProps>;
@@ -332,19 +345,19 @@ export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
index: number,
array: SearchResult_2[],
) => JSX.Element | null;
error?: Error;
loading?: boolean;
noResultsComponent?: ReactNode;
disableRenderingWithNoResults?: boolean;
};
// @public
export type SearchResultGroupProps<FilterOption> = Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
> & {
query: Partial<SearchQuery>;
disableRenderingWithNoResults?: boolean;
};
export type SearchResultGroupProps<FilterOption> = Pick<
SearchResultStateProps,
'query'
> &
Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
>;
// @public
export const SearchResultGroupSelectFilterField: (
@@ -369,6 +382,25 @@ export type SearchResultGroupTextFilterFieldProps =
// @public
export const SearchResultList: (props: SearchResultListProps) => JSX.Element;
// @public
export type SearchResultListItemExtensionOptions<
Component extends (props: any) => JSX.Element | null,
> = {
name: string;
component: () => Promise<Component>;
predicate?: (result: SearchResult_2) => boolean;
};
// @public
export const SearchResultListItemExtensions: (
props: SearchResultListItemExtensionsProps,
) => JSX.Element;
// @public
export type SearchResultListItemExtensionsProps = Omit<ListProps, 'results'> & {
results: SearchResult_2[];
};
// @public
export const SearchResultListLayout: (
props: SearchResultListLayoutProps,
@@ -376,34 +408,31 @@ export const SearchResultListLayout: (
// @public
export type SearchResultListLayoutProps = ListProps & {
error?: Error;
loading?: boolean;
resultItems?: SearchResult_2[];
renderResultItem?: (
value: SearchResult_2,
index: number,
array: SearchResult_2[],
) => JSX.Element | null;
error?: Error;
loading?: boolean;
noResultsComponent?: ReactNode;
disableRenderingWithNoResults?: boolean;
};
// @public
export type SearchResultListProps = Omit<
SearchResultListLayoutProps,
'loading' | 'error' | 'resultItems'
> & {
query: Partial<SearchQuery>;
disableRenderingWithNoResults?: boolean;
};
export type SearchResultListProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListLayoutProps, 'loading' | 'error' | 'resultItems'>;
// @public (undocumented)
export const SearchResultPager: () => JSX.Element;
// @public
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> & {
children: (resultSet: SearchResultSet) => JSX.Element;
noResultsComponent?: JSX.Element;
};
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListItemExtensionsProps, 'results' | 'children'> & {
children?: ReactNode | ((resultSet: SearchResultSet) => JSX.Element);
noResultsComponent?: JSX.Element;
};
// @public
export const SearchResultState: (props: SearchResultStateProps) => JSX.Element;
@@ -420,4 +449,9 @@ export const useSearch: () => SearchContextValue;
// @public
export const useSearchContextCheck: () => boolean;
// @public
export const useSearchResultListItemExtensions: (
children: ReactNode,
) => (result: SearchResult_2, key?: number) => JSX.Element;
```
@@ -15,7 +15,7 @@
*/
import React, { ReactNode } from 'react';
import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { AnalyticsContext } from '@backstage/core-plugin-api';
import {
ResultHighlight,
SearchDocument,
@@ -39,7 +39,7 @@ import { Link } from '@backstage/core-components';
export type DefaultResultListItemProps = {
icon?: ReactNode;
secondaryAction?: ReactNode;
result: SearchDocument;
result?: SearchDocument;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
@@ -53,18 +53,11 @@ 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,
});
};
if (!result) return null;
return (
<>
@@ -73,7 +66,7 @@ export const DefaultResultListItemComponent = ({
<ListItemText
primaryTypographyProps={{ variant: 'h6' }}
primary={
<Link noTrack to={result.location} onClick={handleClick}>
<Link noTrack to={result.location}>
{highlight?.fields.title ? (
<HighlightedSearchResultText
text={highlight?.fields.title || ''}
@@ -23,16 +23,18 @@ import CustomIcon from '@material-ui/icons/NoteAdd';
import { Link } from '@backstage/core-components';
import { TestApiProvider } from '@backstage/test-utils';
import { createPlugin } from '@backstage/core-plugin-api';
import { SearchDocument } from '@backstage/plugin-search-common';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { searchApiRef, MockSearchApi } from '../../api';
import { createSearchResultListItemExtension } from '../../extensions';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResultListLayout } from '../SearchResultList';
import { SearchResultGroupLayout } from '../SearchResultGroup';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResult } from './SearchResult';
import { SearchResultGroupLayout } from '../SearchResultGroup';
const mockResults = {
results: [
@@ -247,3 +249,18 @@ export const WithCustomNoResultsComponent = () => {
</SearchResult>
);
};
export const UsingSearchResultItemExtensions = () => {
const plugin = createPlugin({ id: 'plugin' });
const DefaultResultItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResult>
<DefaultResultItem />
</SearchResult>
);
};
@@ -17,9 +17,19 @@
import React from 'react';
import { waitFor } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { ListItem } from '@material-ui/core';
import {
wrapInTestApp,
renderInTestApp,
renderWithEffects,
TestApiProvider,
} from '@backstage/test-utils';
import { createPlugin } from '@backstage/core-plugin-api';
import { searchApiRef } from '../../api';
import { useSearch } from '../../context';
import { createSearchResultListItemExtension } from '../../extensions';
import { SearchResult } from './SearchResult';
jest.mock('../../context', () => ({
@@ -30,8 +40,12 @@ jest.mock('../../context', () => ({
}));
describe('SearchResult', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Progress rendered on Loading state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: { loading: true },
});
@@ -46,7 +60,7 @@ describe('SearchResult', () => {
it('Alert rendered on Error state', async () => {
const error = new Error('some error');
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: { loading: false, error },
});
@@ -62,7 +76,7 @@ describe('SearchResult', () => {
});
it('On no result value state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: { loading: false, error: '', value: undefined },
});
@@ -78,7 +92,7 @@ describe('SearchResult', () => {
});
it('On empty result value state', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: { loading: false, error: '', value: { results: [] } },
});
@@ -94,7 +108,7 @@ describe('SearchResult', () => {
});
it('On empty result value state with custom component', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: { loading: false, error: '', value: { results: [] } },
});
@@ -110,7 +124,7 @@ describe('SearchResult', () => {
});
it('Calls children with results set to result.value', async () => {
(useSearch as jest.Mock).mockReturnValueOnce({
(useSearch as jest.Mock).mockReturnValue({
result: {
loading: false,
error: '',
@@ -140,4 +154,82 @@ describe('SearchResult', () => {
expect(getByText('Results 1')).toBeInTheDocument();
});
it('Renders results from api', async () => {
const results = [
{
type: 'some-type',
document: {
title: 'some-title',
text: 'some-text',
location: 'some-location',
},
},
];
const query = jest.fn().mockResolvedValue({ results });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchResult query={{ types: ['techdocs'] }}>
{value => {
expect(value.results).toStrictEqual(results);
return <></>;
}}
</SearchResult>
</TestApiProvider>,
),
);
expect(query).toHaveBeenCalledWith({
term: '',
filters: {},
types: ['techdocs'],
});
});
it('Renders using search result item extensions', async () => {
(useSearch as jest.Mock).mockReturnValue({
result: {
loading: false,
error: '',
value: {
totalCount: 1,
results: [
{
type: 'some-type',
document: {
title: 'some-title',
text: 'some-text',
location: 'some-location',
},
},
],
},
},
});
const { getByText, rerender } = await renderInTestApp(<SearchResult />);
expect(getByText('some-title')).toBeInTheDocument();
const SearchResultExtension = createPlugin({
id: 'plugin',
}).provide(
createSearchResultListItemExtension({
name: 'SearchResultExtension',
component: async () => props =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
rerender(
<SearchResult>
<SearchResultExtension />
</SearchResult>,
);
await waitFor(() => {
expect(getByText('Result: some-title')).toBeInTheDocument();
});
});
});
@@ -14,19 +14,24 @@
* limitations under the License.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
import { isFunction } from 'lodash';
import {
EmptyState,
Progress,
EmptyState,
ResponseErrorPanel,
} from '@backstage/core-components';
import { AnalyticsContext, useApi } from '@backstage/core-plugin-api';
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { useSearch } from '../../context';
import { searchApiRef } from '../../api';
import { useSearch } from '../../context';
import {
SearchResultListItemExtensions,
SearchResultListItemExtensionsProps,
} from '../../extensions';
/**
* Props for {@link SearchResultContext}
@@ -36,7 +41,10 @@ export type SearchResultContextProps = {
/**
* A child function that receives an asynchronous result set and returns a react element.
*/
children: (state: AsyncState<SearchResultSet>) => JSX.Element | null;
children: (
state: AsyncState<SearchResultSet>,
query: Partial<SearchQuery>,
) => JSX.Element | null;
};
/**
@@ -62,8 +70,8 @@ export type SearchResultContextProps = {
export const SearchResultContext = (props: SearchResultContextProps) => {
const { children } = props;
const context = useSearch();
const state = context.result;
return children(state);
const { result: state, ...query } = context;
return children(state, query);
};
/**
@@ -103,7 +111,7 @@ export const SearchResultApi = (props: SearchResultApiProps) => {
return searchApi.query({ ...rest, term, types, filters });
}, [query]);
return children(state);
return children(state, query);
};
/**
@@ -165,10 +173,11 @@ export const SearchResultState = (props: SearchResultStateProps) => {
* Props for {@link SearchResult}
* @public
*/
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> & {
children: (resultSet: SearchResultSet) => JSX.Element;
noResultsComponent?: JSX.Element;
};
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListItemExtensionsProps, 'results' | 'children'> & {
children?: ReactNode | ((resultSet: SearchResultSet) => JSX.Element);
noResultsComponent?: JSX.Element;
};
/**
* Renders results from a parent search context or api.
@@ -183,6 +192,7 @@ export const SearchResultComponent = (props: SearchResultProps) => {
noResultsComponent = (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
} = props;
return (
@@ -205,7 +215,15 @@ export const SearchResultComponent = (props: SearchResultProps) => {
return noResultsComponent;
}
return children(value);
if (isFunction(children)) {
return children(value);
}
return (
<SearchResultListItemExtensions {...rest} results={value.results}>
{children}
</SearchResultListItemExtensions>
);
}}
</SearchResultState>
);
@@ -18,8 +18,8 @@ export {
SearchResult,
SearchResultApi,
SearchResultContext,
SearchResultState,
SearchResultComponent,
SearchResultState,
} from './SearchResult';
export type {
@@ -27,11 +27,15 @@ import DocsIcon from '@material-ui/icons/InsertDriveFile';
import { JsonValue } from '@backstage/types';
import { Link } from '@backstage/core-components';
import { createRouteRef } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchContextProvider } from '../../context';
import { searchApiRef, MockSearchApi } from '../../api';
import { createSearchResultListItemExtension } from '../../extensions';
import {
SearchResultGroup,
@@ -83,6 +87,14 @@ export default {
};
export const Default = () => {
return (
<SearchContextProvider>
<SearchResultGroup icon={<DocsIcon />} title="Documentation" />
</SearchContextProvider>
);
};
export const WithQuery = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
@@ -341,3 +353,21 @@ export const WithCustomResultItem = () => {
/>
);
};
export const WithResultItemExtensions = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultGroupItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResultGroup query={query} icon={<DocsIcon />} title="Documentation">
<DefaultSearchResultGroupItem />
</SearchResultGroup>
);
};
@@ -18,16 +18,21 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MenuItem } from '@material-ui/core';
import { ListItem, MenuItem } from '@material-ui/core';
import DocsIcon from '@material-ui/icons/InsertDriveFile';
import {
TestApiProvider,
renderWithEffects,
wrapInTestApp,
renderWithEffects,
TestApiProvider,
MockAnalyticsApi,
} from '@backstage/test-utils';
import { createPlugin, analyticsApiRef } from '@backstage/core-plugin-api';
import { searchApiRef } from '../../api';
import { SearchContextProvider } from '../../context';
import { createSearchResultListItemExtension } from '../../extensions';
import {
SearchResultGroup,
SearchResultGroupSelectFilterField,
@@ -36,6 +41,7 @@ import {
const query = jest.fn().mockResolvedValue({ results: [] });
const searchApiMock = { query };
const analyticsApiMock = new MockAnalyticsApi();
describe('SearchResultGroup', () => {
const results = [
@@ -62,9 +68,18 @@ describe('SearchResultGroup', () => {
});
it('Renders without exploding', async () => {
query.mockResolvedValueOnce({
results,
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -84,10 +99,94 @@ describe('SearchResultGroup', () => {
});
});
it('Defines a default link', async () => {
it('Renders search results from context', async () => {
query.mockResolvedValueOnce({
results,
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchContextProvider>
<SearchResultGroup
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</SearchContextProvider>
</TestApiProvider>,
),
);
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
expect(
screen.getByText('Some text from the search result 1'),
).toBeInTheDocument();
expect(screen.getByText('Search Result 2')).toBeInTheDocument();
expect(
screen.getByText('Some text from the search result 2'),
).toBeInTheDocument();
});
it('Renders search results using extensions', async () => {
query.mockResolvedValueOnce({
results,
});
const SearchResultGroupItemExtension = createPlugin({
id: 'plugin',
}).provide(
createSearchResultListItemExtension({
name: 'SearchResultGroupItemExtension',
component: async () => props =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
>
<SearchResultGroupItemExtension />
</SearchResultGroup>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(screen.getByText('Result: Search Result 1')).toBeInTheDocument();
});
expect(screen.getByText('Result: Search Result 2')).toBeInTheDocument();
});
it('Defines a default link', async () => {
query.mockResolvedValueOnce({
results,
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -108,7 +207,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -132,7 +236,12 @@ describe('SearchResultGroup', () => {
it('Could be customized with no results text', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -154,7 +263,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -184,7 +298,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{
types: ['techdocs'],
@@ -232,7 +351,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{
types: ['techdocs'],
@@ -276,7 +400,12 @@ describe('SearchResultGroup', () => {
query.mockReturnValueOnce(new Promise(() => {}));
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -295,7 +424,12 @@ describe('SearchResultGroup', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -315,7 +449,12 @@ describe('SearchResultGroup', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -335,7 +474,12 @@ describe('SearchResultGroup', () => {
query.mockRejectedValueOnce(new Error());
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -27,9 +27,8 @@ import {
makeStyles,
Theme,
List,
ListSubheader,
ListItem,
ListProps,
ListSubheader,
Menu,
MenuItem,
InputBase,
@@ -43,17 +42,19 @@ import ArrowRightIcon from '@material-ui/icons/ArrowForwardIos';
import { JsonValue } from '@backstage/types';
import {
EmptyState,
Link,
LinkProps,
Progress,
EmptyState,
ResponseErrorPanel,
} from '@backstage/core-components';
import { AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResult } from '@backstage/plugin-search-common';
import { SearchResult } from '@backstage/plugin-search-common';
import { useSearchResultListItemExtensions } from '../../extensions';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResultState } from '../SearchResult';
import { SearchResultState, SearchResultStateProps } from '../SearchResult';
const useStyles = makeStyles((theme: Theme) => ({
listSubheader: {
@@ -278,6 +279,14 @@ export const SearchResultGroupSelectFilterField = (
* @public
*/
export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
/**
* If defined, will render a default error panel.
*/
error?: Error;
/**
* If defined, will render a default loading progress.
*/
loading?: boolean;
/**
* Icon that representing a result group.
*/
@@ -331,18 +340,14 @@ export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
index: number,
array: SearchResult[],
) => JSX.Element | null;
/**
* If defined, will render a default error panel.
*/
error?: Error;
/**
* If defined, will render a default loading progress.
*/
loading?: boolean;
/**
* Optional component to render when no results. Default to <EmptyState /> component.
*/
noResultsComponent?: ReactNode;
/**
* Optional property to provide if component should not render the component when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
/**
@@ -357,8 +362,8 @@ export function SearchResultGroupLayout<FilterOption>(
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
loading,
error,
loading,
icon,
title,
titleProps = {},
@@ -384,7 +389,8 @@ export function SearchResultGroupLayout<FilterOption>(
result={resultItem.document}
/>
),
noResultsComponent = (
disableRenderingWithNoResults,
noResultsComponent = disableRenderingWithNoResults ? null : (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
@@ -398,6 +404,23 @@ export function SearchResultGroupLayout<FilterOption>(
setAnchorEl(null);
}, []);
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!resultItems?.length) {
return <>{noResultsComponent}</>;
}
return (
<List {...rest}>
<ListSubheader className={classes.listSubheader}>
@@ -440,19 +463,7 @@ export function SearchResultGroupLayout<FilterOption>(
{link}
</Link>
</ListSubheader>
{loading ? <Progress /> : null}
{!loading && error ? (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(renderResultItem)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>{noResultsComponent}</ListItem>
) : null}
{resultItems.map(renderResultItem)}
</List>
);
}
@@ -461,19 +472,14 @@ export function SearchResultGroupLayout<FilterOption>(
* Props for {@link SearchResultGroup}.
* @public
*/
export type SearchResultGroupProps<FilterOption> = Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
> & {
/**
* A search query used for requesting the results to be grouped.
*/
query: Partial<SearchQuery>;
/**
* Optional property to provide if component should not render the group when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
export type SearchResultGroupProps<FilterOption> = Pick<
SearchResultStateProps,
'query'
> &
Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
>;
/**
* Given a query, search for results and render them as a group.
@@ -483,22 +489,9 @@ export type SearchResultGroupProps<FilterOption> = Omit<
export function SearchResultGroup<FilterOption>(
props: SearchResultGroupProps<FilterOption>,
) {
const {
query,
linkProps = {},
disableRenderingWithNoResults,
...rest
} = props;
const { query, children, renderResultItem, linkProps = {}, ...rest } = props;
const to = `/search?${qs.stringify(
{
query: query.term,
types: query.types,
filters: query.filters,
pageCursor: query.pageCursor,
},
{ arrayFormat: 'brackets' },
)}`;
const defaultRenderResultItem = useSearchResultListItemExtensions(children);
return (
<AnalyticsContext
@@ -508,19 +501,24 @@ export function SearchResultGroup<FilterOption>(
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => {
if (!value?.results?.length && disableRenderingWithNoResults) {
return null;
}
{(
{ loading, error, value },
{ term, types, pageCursor, filters = {} },
) => {
const to = `/search?${qs.stringify(
{ term, types, filters, pageCursor, query: term },
{ arrayFormat: 'brackets' },
)}`;
return (
<SearchResultGroupLayout
{...rest}
loading={loading}
error={error}
loading={loading}
linkProps={{ to, ...linkProps }}
filterFields={Object.keys(filters)}
resultItems={value?.results}
filterFields={Object.keys(query.filters ?? {})}
renderResultItem={renderResultItem ?? defaultRenderResultItem}
/>
);
}}
@@ -18,12 +18,14 @@ import React, { ComponentType, useState } from 'react';
import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
import { createRouteRef } from '@backstage/core-plugin-api';
import { CatalogIcon, Link } from '@backstage/core-components';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
import { createPlugin, createRouteRef } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { SearchContextProvider } from '../../context';
import { searchApiRef, MockSearchApi } from '../../api';
import { createSearchResultListItemExtension } from '../../extensions';
import { SearchResultList } from './SearchResultList';
import { DefaultResultListItem } from '../DefaultResultListItem';
@@ -72,6 +74,14 @@ export default {
};
export const Default = () => {
return (
<SearchContextProvider>
<SearchResultList />
</SearchContextProvider>
);
};
export const WithQuery = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
@@ -195,3 +205,21 @@ export const WithCustomResultItem = () => {
/>
);
};
export const WithResultItemExtensions = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultListItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResultList query={query}>
<DefaultSearchResultListItem />
</SearchResultList>
);
};
@@ -17,17 +17,23 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { ListItem } from '@material-ui/core';
import {
TestApiProvider,
renderWithEffects,
wrapInTestApp,
MockAnalyticsApi,
} from '@backstage/test-utils';
import { analyticsApiRef, createPlugin } from '@backstage/core-plugin-api';
import { searchApiRef } from '../../api';
import { createSearchResultListItemExtension } from '../../extensions';
import { SearchResultList } from './SearchResultList';
const query = jest.fn().mockResolvedValue({ results: [] });
const searchApiMock = { query };
const analyticsApiMock = new MockAnalyticsApi();
describe('SearchResultList', () => {
const results = [
@@ -56,7 +62,12 @@ describe('SearchResultList', () => {
it('Renders without exploding', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -81,7 +92,12 @@ describe('SearchResultList', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -106,7 +122,12 @@ describe('SearchResultList', () => {
query.mockReturnValueOnce(new Promise(() => {}));
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -125,7 +146,12 @@ describe('SearchResultList', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{ types: ['techdocs'] }}
disableRenderingWithNoResults
@@ -143,7 +169,12 @@ describe('SearchResultList', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{ types: ['techdocs'] }}
noResultsComponent="No results were found"
@@ -161,7 +192,12 @@ describe('SearchResultList', () => {
query.mockRejectedValueOnce(new Error());
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -179,4 +215,45 @@ describe('SearchResultList', () => {
).toBeInTheDocument();
});
});
it('should render search results using list item extensions', async () => {
query.mockResolvedValueOnce({
results,
});
const SearchResultListItemExtension = createPlugin({
id: 'plugin',
}).provide(
createSearchResultListItemExtension({
name: 'SearchResultListItemExtension',
component: async () => props =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
}}
>
<SearchResultListItemExtension />
</SearchResultList>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(screen.getByText('Result: Search Result 1')).toBeInTheDocument();
});
expect(screen.getByText('Result: Search Result 2')).toBeInTheDocument();
});
});
@@ -16,24 +16,34 @@
import React, { ReactNode } from 'react';
import { List, ListItem, ListProps } from '@material-ui/core';
import { List, ListProps } from '@material-ui/core';
import {
EmptyState,
Progress,
EmptyState,
ResponseErrorPanel,
} from '@backstage/core-components';
import { AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResult } from '@backstage/plugin-search-common';
import { SearchResult } from '@backstage/plugin-search-common';
import { useSearchResultListItemExtensions } from '../../extensions';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResultState } from '../SearchResult';
import { SearchResultState, SearchResultStateProps } from '../SearchResult';
/**
* Props for {@link SearchResultListLayout}
* @public
*/
export type SearchResultListLayoutProps = ListProps & {
/**
* If defined, will render a default error panel.
*/
error?: Error;
/**
* If defined, will render a default loading progress.
*/
loading?: boolean;
/**
* Search results to be rendered as a list.
*/
@@ -46,18 +56,14 @@ export type SearchResultListLayoutProps = ListProps & {
index: number,
array: SearchResult[],
) => JSX.Element | null;
/**
* If defined, will render a default error panel.
*/
error?: Error;
/**
* If defined, will render a default loading progress.
*/
loading?: boolean;
/**
* Optional component to render when no results. Default to <EmptyState /> component.
*/
noResultsComponent?: ReactNode;
/**
* Optional property to provide if component should not render the component when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
/**
@@ -67,8 +73,8 @@ export type SearchResultListLayoutProps = ListProps & {
*/
export const SearchResultListLayout = (props: SearchResultListLayoutProps) => {
const {
loading,
error,
loading,
resultItems,
renderResultItem = resultItem => (
<DefaultResultListItem
@@ -76,48 +82,39 @@ export const SearchResultListLayout = (props: SearchResultListLayoutProps) => {
result={resultItem.document}
/>
),
noResultsComponent = (
disableRenderingWithNoResults,
noResultsComponent = disableRenderingWithNoResults ? null : (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
} = props;
return (
<List {...rest}>
{loading ? <Progress /> : null}
{!loading && error ? (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(renderResultItem)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>{noResultsComponent}</ListItem>
) : null}
</List>
);
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!resultItems?.length) {
return <>{noResultsComponent}</>;
}
return <List {...rest}>{resultItems.map(renderResultItem)}</List>;
};
/**
* Props for {@link SearchResultList}.
* @public
*/
export type SearchResultListProps = Omit<
SearchResultListLayoutProps,
'loading' | 'error' | 'resultItems'
> & {
/**
* A search query used for requesting the results to be listed.
*/
query: Partial<SearchQuery>;
/**
* Optional property to provide if component should not render the component when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
export type SearchResultListProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListLayoutProps, 'loading' | 'error' | 'resultItems'>;
/**
* Given a query, search for results and render them as a list.
@@ -125,7 +122,9 @@ export type SearchResultListProps = Omit<
* @public
*/
export const SearchResultList = (props: SearchResultListProps) => {
const { query, disableRenderingWithNoResults, ...rest } = props;
const { query, renderResultItem, children, ...rest } = props;
const defaultRenderResultItem = useSearchResultListItemExtensions(children);
return (
<AnalyticsContext
@@ -135,20 +134,15 @@ export const SearchResultList = (props: SearchResultListProps) => {
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => {
if (!value?.results?.length && disableRenderingWithNoResults) {
return null;
}
return (
<SearchResultListLayout
{...rest}
loading={loading}
error={error}
resultItems={value?.results}
/>
);
}}
{({ loading, error, value }) => (
<SearchResultListLayout
{...rest}
error={error}
loading={loading}
resultItems={value?.results}
renderResultItem={renderResultItem ?? defaultRenderResultItem}
/>
)}
</SearchResultState>
</AnalyticsContext>
);
+2 -2
View File
@@ -18,9 +18,9 @@ export * from './HighlightedSearchResultText';
export * from './SearchBar';
export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchPagination';
export * from './SearchResult';
export * from './SearchResultList';
export * from './SearchResultGroup';
export * from './SearchResultPager';
export * from './DefaultResultListItem';
@@ -0,0 +1,209 @@
/*
* Copyright 2023 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 userEvent from '@testing-library/user-event';
import { ListItem, ListItemText } from '@material-ui/core';
import {
wrapInTestApp,
renderWithEffects,
TestApiProvider,
MockAnalyticsApi,
} from '@backstage/test-utils';
import {
createPlugin,
BackstagePlugin,
analyticsApiRef,
} from '@backstage/core-plugin-api';
import { SearchResult, SearchDocument } from '@backstage/plugin-search-common';
import {
SearchResultListItemExtensions,
createSearchResultListItemExtension,
SearchResultListItemExtensionOptions,
} from './extensions';
const analyticsApiMock = new MockAnalyticsApi();
const results = [
{
type: 'explore',
document: {
location: 'search/search-result1',
title: 'Search Result 1',
text: 'Some text from the search result 1',
},
},
{
type: 'techdocs',
document: {
location: 'search/search-result2',
title: 'Search Result 2',
text: 'Some text from the search result 2',
},
},
];
const createExtension = (
plugin: BackstagePlugin,
options: Partial<
Omit<
SearchResultListItemExtensionOptions<
(props: { result?: SearchDocument }) => JSX.Element | null
>,
'name'
>
> = {},
) => {
const {
predicate,
component = async () => (props: { result?: SearchDocument }) =>
(
<ListItem>
<ListItemText primary="Default" secondary={props.result?.title} />
</ListItem>
),
} = options;
return plugin.provide(
createSearchResultListItemExtension({
predicate,
component,
name: 'TestSearchResultItemExtension',
}),
);
};
describe('extensions', () => {
it('renders without exploding', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<SearchResultListItemExtensions results={results} />
</TestApiProvider>,
),
);
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
expect(
screen.getByText('Some text from the search result 1'),
).toBeInTheDocument();
expect(screen.getByText('Search Result 2')).toBeInTheDocument();
expect(
screen.getByText('Some text from the search result 2'),
).toBeInTheDocument();
});
it('capture results discovery events', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<SearchResultListItemExtensions results={results} />
</TestApiProvider>,
),
);
await userEvent.click(
screen.getByRole('button', { name: /Search Result 1/ }),
);
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'discover',
subject: 'Search Result 1',
context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' },
attributes: { to: 'search/search-result1' },
});
});
it('Could be used as simple components', async () => {
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultListItemExtension = createExtension(plugin);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<DefaultSearchResultListItemExtension result={results[0].document} />
</TestApiProvider>,
),
);
expect(screen.getByText('Default')).toBeInTheDocument();
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
await userEvent.click(
screen.getByRole('button', { name: /Search Result 1/ }),
);
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'discover',
subject: 'Search Result 1',
context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' },
attributes: { to: 'search/search-result1' },
});
});
it('use default options for rendering results', async () => {
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultListItemExtension = createExtension(plugin);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<SearchResultListItemExtensions results={results}>
<DefaultSearchResultListItemExtension />
</SearchResultListItemExtensions>
</TestApiProvider>,
),
);
expect(screen.getAllByText('Default')).toHaveLength(2);
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
expect(screen.getByText('Search Result 2')).toBeInTheDocument();
});
it('use custom options for rendering results', async () => {
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultListItemExtension = createExtension(plugin);
const ExploreSearchResultListItemExtension = createExtension(plugin, {
predicate: (result: SearchResult) => result.type === 'explore',
component: async () => (props: { result?: SearchDocument }) =>
(
<ListItem>
<ListItemText primary="Explore" secondary={props.result?.title} />
</ListItem>
),
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
<SearchResultListItemExtensions results={results}>
<ExploreSearchResultListItemExtension />
<DefaultSearchResultListItemExtension />
</SearchResultListItemExtensions>
</TestApiProvider>,
),
);
expect(screen.getAllByText('Default')).toHaveLength(1);
expect(screen.getAllByText('Explore')).toHaveLength(1);
expect(screen.getByText('Search Result 1')).toBeInTheDocument();
expect(screen.getByText('Search Result 2')).toBeInTheDocument();
});
});
+238
View File
@@ -0,0 +1,238 @@
/*
* Copyright 2023 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, {
Fragment,
ReactNode,
PropsWithChildren,
isValidElement,
createElement,
cloneElement,
useCallback,
} from 'react';
import {
getComponentData,
useElementFilter,
Extension,
createReactExtension,
useAnalytics,
} from '@backstage/core-plugin-api';
import { SearchDocument, SearchResult } from '@backstage/plugin-search-common';
import { Box, List, ListProps } from '@material-ui/core';
import { DefaultResultListItem } from './components';
/**
* @internal
* Key for result extensions.
*/
const SEARCH_RESULT_LIST_ITEM_EXTENSION =
'search.results.list.items.extensions.v1';
/**
* @internal
* Returns the first extension element found for a given result, and null otherwise.
* @param elements - All extension elements.
* @param result - The search result.
*/
const findSearchResultListItemExtensionElement = (
elements: ReactNode[],
result: SearchResult,
) => {
for (const element of elements) {
if (!isValidElement(element)) continue;
const predicate = getComponentData<(result: SearchResult) => boolean>(
element,
SEARCH_RESULT_LIST_ITEM_EXTENSION,
);
if (!predicate?.(result)) continue;
return cloneElement(element, {
rank: result.rank,
highlight: result.highlight,
result: result.document,
// Use props in situations where a consumer is manually rendering the extension
...element.props,
});
}
return null;
};
/**
* @internal
* Props for {@link SearchResultListItemExtension}.
*/
type SearchResultListItemExtensionProps = PropsWithChildren<{
rank?: number;
result?: SearchDocument;
noTrack?: boolean;
}>;
/**
* @internal
* Extends children with extension capabilities.
* @param props - see {@link SearchResultListItemExtensionProps}.
*/
const SearchResultListItemExtension = ({
rank,
result,
noTrack,
children,
}: SearchResultListItemExtensionProps) => {
const analytics = useAnalytics();
const handleClickCapture = useCallback(() => {
if (noTrack) return;
if (!result) return;
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: rank,
});
}, [rank, result, noTrack, analytics]);
return (
<Box role="button" tabIndex={0} onClickCapture={handleClickCapture}>
{children}
</Box>
);
};
/**
* @public
* Options for {@link createSearchResultListItemExtension}.
*/
export type SearchResultListItemExtensionOptions<
Component extends (props: any) => JSX.Element | null,
> = {
/**
* The extension name.
*/
name: string;
/**
* The extension component.
*/
component: () => Promise<Component>;
/**
* When an extension defines a predicate, it returns true if the result should be rendered by that extension.
* Defaults to a predicate that returns true, which means it renders all sorts of results.
*/
predicate?: (result: SearchResult) => boolean;
};
/**
* @public
* Creates a search result item extension.
* @param options - The extension options, see {@link SearchResultListItemExtensionOptions} for more details.
*/
export const createSearchResultListItemExtension = <
Component extends (props: any) => JSX.Element | null,
>(
options: SearchResultListItemExtensionOptions<Component>,
): Extension<Component> => {
const { name, component, predicate = () => true } = options;
return createReactExtension<Component>({
name,
component: {
lazy: () =>
component().then(
type =>
(props => (
<SearchResultListItemExtension
rank={props.rank}
result={props.result}
noTrack={props.noTrack}
>
{createElement(type, props)}
</SearchResultListItemExtension>
)) as Component,
),
},
data: {
[SEARCH_RESULT_LIST_ITEM_EXTENSION]: predicate,
},
});
};
/**
* @public
* Returns a function that renders a result using extensions.
*/
export const useSearchResultListItemExtensions = (children: ReactNode) => {
const elements = useElementFilter(
children,
collection => {
return collection
.selectByComponentData({
key: SEARCH_RESULT_LIST_ITEM_EXTENSION,
})
.getElements();
},
[children],
);
return useCallback(
(result: SearchResult, key?: number) => {
const element = findSearchResultListItemExtensionElement(
elements,
result,
);
return (
<Fragment key={key}>
{element ?? (
<SearchResultListItemExtension
rank={result.rank}
result={result.document}
>
<DefaultResultListItem
rank={result.rank}
highlight={result.highlight}
result={result.document}
/>
</SearchResultListItemExtension>
)}
</Fragment>
);
},
[elements],
);
};
/**
* @public
* Props for {@link SearchResultListItemExtensions}
*/
export type SearchResultListItemExtensionsProps = Omit<ListProps, 'results'> & {
/**
* Search result list.
*/
results: SearchResult[];
};
/**
* @public
* Render results using search extensions.
* @param props - see {@link SearchResultListItemExtensionsProps}
*/
export const SearchResultListItemExtensions = (
props: SearchResultListItemExtensionsProps,
) => {
const { results, children, ...rest } = props;
const render = useSearchResultListItemExtensions(children);
return <List {...rest}>{results.map(render)}</List>;
};
+1
View File
@@ -22,6 +22,7 @@
export { searchApiRef, MockSearchApi } from './api';
export type { SearchApi } from './api';
export * from './extensions';
export * from './components';
export {
SearchContextProvider,
@@ -23,7 +23,6 @@ import {
DialogTitle,
Divider,
Grid,
List,
Paper,
useTheme,
} from '@material-ui/core';
@@ -31,7 +30,6 @@ import Typography from '@material-ui/core/Typography';
import LaunchIcon from '@material-ui/icons/Launch';
import { makeStyles } from '@material-ui/core/styles';
import {
DefaultResultListItem,
SearchContextProvider,
SearchBar,
SearchResult,
@@ -151,27 +149,10 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
</Grid>
</Grid>
<Divider />
<SearchResult>
{({ results }) => (
<List>
{results.map(({ document, highlight }) => (
<div
role="button"
tabIndex={0}
key={`${document.location}-btn`}
onClick={handleSearchResultClick}
onKeyDown={handleSearchResultClick}
>
<DefaultResultListItem
key={document.location}
result={document}
highlight={highlight}
/>
</div>
))}
</List>
)}
</SearchResult>
<SearchResult
onClick={handleSearchResultClick}
onKeyDown={handleSearchResultClick}
/>
</DialogContent>
<DialogActions className={classes.dialogActionsContainer}>
<Grid container direction="row">
+1 -1
View File
@@ -47,7 +47,7 @@ export type { SidebarSearchModalProps } from './components/SidebarSearchModal';
export {
HomePageSearchBar,
SearchPage,
SidebarSearchModal,
searchPlugin as plugin,
searchPlugin,
SidebarSearchModal,
} from './plugin';
+2 -2
View File
@@ -398,12 +398,12 @@ export type TechDocsSearchProps = {
// @public
export const TechDocsSearchResultListItem: (
props: TechDocsSearchResultListItemProps,
) => JSX.Element;
) => JSX.Element | null;
// @public
export type TechDocsSearchResultListItemProps = {
icon?: ReactNode;
result: any;
result?: any;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
+3
View File
@@ -37,11 +37,14 @@ export {
TechDocsIndexPage,
TechdocsPage,
TechDocsReaderPage,
TechDocsSearchResultListItem,
techdocsPlugin as plugin,
techdocsPlugin,
} from './plugin';
export * from './Router';
export type { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem';
/**
* @deprecated Import from `@backstage/plugin-techdocs-react` instead
*
+20
View File
@@ -33,6 +33,8 @@ import {
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react';
import { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem';
/**
* The Backstage plugin that renders technical documentation for your components
@@ -153,3 +155,21 @@ export const TechDocsReaderPage = techdocsPlugin.provide(
mountPoint: rootDocsRouteRef,
}),
);
/**
* React extension used to render results on Search page or modal
*
* @public
*/
export const TechDocsSearchResultListItem: (
props: TechDocsSearchResultListItemProps,
) => JSX.Element | null = techdocsPlugin.provide(
createSearchResultListItemExtension({
name: 'TechDocsSearchResultListItem',
component: () =>
import('./search/components/TechDocsSearchResultListItem').then(
m => m.TechDocsSearchResultListItem,
),
predicate: result => result.type === 'techdocs',
}),
);
@@ -24,7 +24,6 @@ import {
} from '@material-ui/core';
import Typography from '@material-ui/core/Typography';
import { Link } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
@@ -45,7 +44,7 @@ const useStyles = makeStyles({
*/
export type TechDocsSearchResultListItemProps = {
icon?: ReactNode;
result: any;
result?: any;
highlight?: ResultHighlight;
rank?: number;
lineClamp?: number;
@@ -65,7 +64,6 @@ export const TechDocsSearchResultListItem = (
const {
result,
highlight,
rank,
lineClamp = 5,
asListItem = true,
asLink = true,
@@ -74,17 +72,9 @@ export const TechDocsSearchResultListItem = (
} = props;
const classes = useStyles();
const analytics = useAnalytics();
const handleClick = () => {
analytics.captureEvent('discover', result.title, {
attributes: { to: result.location },
value: rank,
});
};
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
asLink ? (
<Link noTrack to={result.location} onClick={handleClick}>
<Link noTrack to={result.location}>
{children}
</Link>
) : (
@@ -122,6 +112,8 @@ export const TechDocsSearchResultListItem = (
result.name
);
if (!result) return null;
return (
<ListItemText
className={classes.itemText}
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './TechDocsSearchResultListItem';
export * from './TechDocsSearch';