diff --git a/.changeset/gold-bulldogs-talk.md b/.changeset/gold-bulldogs-talk.md new file mode 100644 index 0000000000..e5b6254ec5 --- /dev/null +++ b/.changeset/gold-bulldogs-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': minor +--- + +Update `SearchModal` component to use `SearchResult` extensions. diff --git a/.changeset/nasty-beans-accept.md b/.changeset/nasty-beans-accept.md new file mode 100644 index 0000000000..05ccb64c33 --- /dev/null +++ b/.changeset/nasty-beans-accept.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': minor +--- + +Update `SearchPage` template to use `SearchResult` extensions. diff --git a/.changeset/old-knives-wonder.md b/.changeset/old-knives-wonder.md new file mode 100644 index 0000000000..93d2be4871 --- /dev/null +++ b/.changeset/old-knives-wonder.md @@ -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. diff --git a/.changeset/rich-snakes-rest.md b/.changeset/rich-snakes-rest.md new file mode 100644 index 0000000000..7b42491ba1 --- /dev/null +++ b/.changeset/rich-snakes-rest.md @@ -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. diff --git a/.changeset/stupid-ladybugs-brake.md b/.changeset/stupid-ladybugs-brake.md new file mode 100644 index 0000000000..5a14b82937 --- /dev/null +++ b/.changeset/stupid-ladybugs-brake.md @@ -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. diff --git a/.changeset/witty-moose-itch.md b/.changeset/witty-moose-itch.md new file mode 100644 index 0000000000..e2593bf7e7 --- /dev/null +++ b/.changeset/witty-moose-itch.md @@ -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. diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index eb162ce527..6f113630f6 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -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 = () => ( + +
+ + + + + + + + + + + } /> + } /> + } /> + + + + + +); + +export const 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 }) => ( + <> + + + + + + + + } /> + } /> + } /> + {/* 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 */} + + + + +); +``` + +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). diff --git a/packages/app/src/components/search/SearchModal.tsx b/packages/app/src/components/search/SearchModal.tsx index 527351a857..4694d56390 100644 --- a/packages/app/src/components/search/SearchModal.tsx +++ b/packages/app/src/components/search/SearchModal.tsx @@ -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) => { 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" > - + void }) => { - - {({ results }) => ( - - {results.map(({ type, document, highlight, rank }) => { - let resultItem; - switch (type) { - case 'software-catalog': - resultItem = ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - break; - case 'techdocs': - resultItem = ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - break; - case 'tools': - resultItem = ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - break; - default: - resultItem = ( - - ); - } - return ( -
- {resultItem} -
- ); - })} -
- )} + + } /> + } /> + } />
diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index b79c6a7248..8167889b02 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -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 = () => { - {({ results }) => ( - - {results.map(({ type, document, highlight, rank }) => { - switch (type) { - case 'software-catalog': - return ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - case 'techdocs': - return ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - case 'tools': - return ( - } - key={document.location} - result={document} - highlight={highlight} - rank={rank} - /> - ); - default: - return ( - - ); - } - })} - - )} + } /> + } /> + } /> 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 9f11d0c80c..1788dde1bd 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 @@ -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 = () => { - {({ results }) => ( - - {results.map(({ type, document, highlight, rank }) => { - switch (type) { - case 'software-catalog': - return ( - - ); - case 'techdocs': - return ( - - ); - default: - return ( - - ); - } - })} - - )} + } /> + } /> diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 82df851239..901120bd22 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -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) diff --git a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx index 2e5cbe2899..cf60138d99 100644 --- a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx +++ b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx @@ -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={ - + {highlight?.fields.title ? ( ( }, }), ); + +/** @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', + }), +); diff --git a/plugins/explore/api-report.md b/plugins/explore/api-report.md index d184ce01a4..e7adde4b77 100644 --- a/plugins/explore/api-report.md +++ b/plugins/explore/api-report.md @@ -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; } ``` diff --git a/plugins/explore/src/components/ToolSearchResultListItem/ToolSearchResultListItem.tsx b/plugins/explore/src/components/ToolSearchResultListItem/ToolSearchResultListItem.tsx index 3f5a515efb..c9e8946f26 100644 --- a/plugins/explore/src/components/ToolSearchResultListItem/ToolSearchResultListItem.tsx +++ b/plugins/explore/src/components/ToolSearchResultListItem/ToolSearchResultListItem.tsx @@ -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={ - + {props.highlight?.fields.title ? ( JSX.Element | null = explorePlugin.provide( + createSearchResultListItemExtension({ + name: 'ToolSearchResultListItem', + component: () => + import('./components/ToolSearchResultListItem').then( + m => m.ToolSearchResultListItem, + ), + predicate: result => result.type === 'tools', + }), +); diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 3d1e4091ea..ccf7a72e33 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -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, +) => Extension; + // @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) => JSX.Element | null; + children: ( + state: AsyncState, + query: Partial, + ) => JSX.Element | null; }; // @public @@ -313,6 +324,8 @@ export function SearchResultGroupLayout( // @public export type SearchResultGroupLayoutProps = ListProps & { + error?: Error; + loading?: boolean; icon: JSX.Element; title: ReactNode; titleProps?: Partial; @@ -332,19 +345,19 @@ export type SearchResultGroupLayoutProps = ListProps & { index: number, array: SearchResult_2[], ) => JSX.Element | null; - error?: Error; - loading?: boolean; noResultsComponent?: ReactNode; + disableRenderingWithNoResults?: boolean; }; // @public -export type SearchResultGroupProps = Omit< - SearchResultGroupLayoutProps, - 'loading' | 'error' | 'resultItems' | 'filterFields' -> & { - query: Partial; - disableRenderingWithNoResults?: boolean; -}; +export type SearchResultGroupProps = Pick< + SearchResultStateProps, + 'query' +> & + Omit< + SearchResultGroupLayoutProps, + '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; + predicate?: (result: SearchResult_2) => boolean; +}; + +// @public +export const SearchResultListItemExtensions: ( + props: SearchResultListItemExtensionsProps, +) => JSX.Element; + +// @public +export type SearchResultListItemExtensionsProps = Omit & { + 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; - disableRenderingWithNoResults?: boolean; -}; +export type SearchResultListProps = Pick & + Omit; // @public (undocumented) export const SearchResultPager: () => JSX.Element; // @public -export type SearchResultProps = Pick & { - children: (resultSet: SearchResultSet) => JSX.Element; - noResultsComponent?: JSX.Element; -}; +export type SearchResultProps = Pick & + Omit & { + 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; ``` diff --git a/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx b/plugins/search-react/src/components/DefaultResultListItem/DefaultResultListItem.tsx index 6158aed008..6ea5e4d1a2 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, 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 = ({ + {highlight?.fields.title ? ( { ); }; + +export const UsingSearchResultItemExtensions = () => { + const plugin = createPlugin({ id: 'plugin' }); + const DefaultResultItem = plugin.provide( + createSearchResultListItemExtension({ + name: 'DefaultResultListItem', + component: async () => DefaultResultListItem, + }), + ); + return ( + + + + ); +}; diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx index 5972eaa891..6a5a683295 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.test.tsx @@ -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( + + + {value => { + expect(value.results).toStrictEqual(results); + return <>; + }} + + , + ), + ); + + 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(); + + expect(getByText('some-title')).toBeInTheDocument(); + + const SearchResultExtension = createPlugin({ + id: 'plugin', + }).provide( + createSearchResultListItemExtension({ + name: 'SearchResultExtension', + component: async () => props => + Result: {props.result?.title}, + }), + ); + + rerender( + + + , + ); + + await waitFor(() => { + expect(getByText('Result: some-title')).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.tsx index 333156aab9..f362ff27d3 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.tsx @@ -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) => JSX.Element | null; + children: ( + state: AsyncState, + query: Partial, + ) => 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 & { - children: (resultSet: SearchResultSet) => JSX.Element; - noResultsComponent?: JSX.Element; -}; +export type SearchResultProps = Pick & + Omit & { + 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 = ( ), + ...rest } = props; return ( @@ -205,7 +215,15 @@ export const SearchResultComponent = (props: SearchResultProps) => { return noResultsComponent; } - return children(value); + if (isFunction(children)) { + return children(value); + } + + return ( + + {children} + + ); }} ); diff --git a/plugins/search-react/src/components/SearchResult/index.tsx b/plugins/search-react/src/components/SearchResult/index.tsx index eab599883b..cf36b7ec9e 100644 --- a/plugins/search-react/src/components/SearchResult/index.tsx +++ b/plugins/search-react/src/components/SearchResult/index.tsx @@ -18,8 +18,8 @@ export { SearchResult, SearchResultApi, SearchResultContext, - SearchResultState, SearchResultComponent, + SearchResultState, } from './SearchResult'; export type { diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx index fab807dd84..40f7d30ff4 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx @@ -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 ( + + } title="Documentation" /> + + ); +}; + +export const WithQuery = () => { const [query] = useState>({ types: ['techdocs'], }); @@ -341,3 +353,21 @@ export const WithCustomResultItem = () => { /> ); }; + +export const WithResultItemExtensions = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + const plugin = createPlugin({ id: 'plugin' }); + const DefaultSearchResultGroupItem = plugin.provide( + createSearchResultListItemExtension({ + name: 'DefaultResultListItem', + component: async () => DefaultResultListItem, + }), + ); + return ( + } title="Documentation"> + + + ); +}; diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx index 7943dba3f7..728bd9113a 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx @@ -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( - + } @@ -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( - + + + } + title="Documentation" + /> + + , + ), + ); + + 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 => + Result: {props.result?.title}, + }), + ); + + await renderWithEffects( + wrapInTestApp( + + } + title="Documentation" + > + + + , + ), + ); + + 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( + } @@ -108,7 +207,12 @@ describe('SearchResultGroup', () => { await renderWithEffects( wrapInTestApp( - + } @@ -132,7 +236,12 @@ describe('SearchResultGroup', () => { it('Could be customized with no results text', async () => { await renderWithEffects( wrapInTestApp( - + } @@ -154,7 +263,12 @@ describe('SearchResultGroup', () => { await renderWithEffects( wrapInTestApp( - + } @@ -184,7 +298,12 @@ describe('SearchResultGroup', () => { await renderWithEffects( wrapInTestApp( - + { await renderWithEffects( wrapInTestApp( - + { query.mockReturnValueOnce(new Promise(() => {})); await renderWithEffects( wrapInTestApp( - + } @@ -295,7 +424,12 @@ describe('SearchResultGroup', () => { query.mockResolvedValueOnce({ results: [] }); await renderWithEffects( wrapInTestApp( - + } @@ -315,7 +449,12 @@ describe('SearchResultGroup', () => { query.mockResolvedValueOnce({ results: [] }); await renderWithEffects( wrapInTestApp( - + } @@ -335,7 +474,12 @@ describe('SearchResultGroup', () => { query.mockRejectedValueOnce(new Error()); await renderWithEffects( wrapInTestApp( - + } diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx index 6040dbe560..2a3ae1e24c 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx @@ -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 = 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 = 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 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( const [anchorEl, setAnchorEl] = useState(null); const { - loading, error, + loading, icon, title, titleProps = {}, @@ -384,7 +389,8 @@ export function SearchResultGroupLayout( result={resultItem.document} /> ), - noResultsComponent = ( + disableRenderingWithNoResults, + noResultsComponent = disableRenderingWithNoResults ? null : ( ), ...rest @@ -398,6 +404,23 @@ export function SearchResultGroupLayout( setAnchorEl(null); }, []); + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + if (!resultItems?.length) { + return <>{noResultsComponent}; + } + return ( @@ -440,19 +463,7 @@ export function SearchResultGroupLayout( {link} - {loading ? : null} - {!loading && error ? ( - - ) : null} - {!loading && !error && resultItems?.length - ? resultItems.map(renderResultItem) - : null} - {!loading && !error && !resultItems?.length ? ( - {noResultsComponent} - ) : null} + {resultItems.map(renderResultItem)} ); } @@ -461,19 +472,14 @@ export function SearchResultGroupLayout( * Props for {@link SearchResultGroup}. * @public */ -export type SearchResultGroupProps = Omit< - SearchResultGroupLayoutProps, - 'loading' | 'error' | 'resultItems' | 'filterFields' -> & { - /** - * A search query used for requesting the results to be grouped. - */ - query: Partial; - /** - * Optional property to provide if component should not render the group when no results are found. - */ - disableRenderingWithNoResults?: boolean; -}; +export type SearchResultGroupProps = Pick< + SearchResultStateProps, + 'query' +> & + Omit< + SearchResultGroupLayoutProps, + 'loading' | 'error' | 'resultItems' | 'filterFields' + >; /** * Given a query, search for results and render them as a group. @@ -483,22 +489,9 @@ export type SearchResultGroupProps = Omit< export function SearchResultGroup( props: SearchResultGroupProps, ) { - 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 ( ( }} > - {({ 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 ( ); }} diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx index fcda4cec87..4369b6636e 100644 --- a/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx @@ -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 ( + + + + ); +}; + +export const WithQuery = () => { const [query] = useState>({ types: ['techdocs'], }); @@ -195,3 +205,21 @@ export const WithCustomResultItem = () => { /> ); }; + +export const WithResultItemExtensions = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + const plugin = createPlugin({ id: 'plugin' }); + const DefaultSearchResultListItem = plugin.provide( + createSearchResultListItemExtension({ + name: 'DefaultResultListItem', + component: async () => DefaultResultListItem, + }), + ); + return ( + + + + ); +}; diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx index 93bbaa5602..7e8aa7bb33 100644 --- a/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx @@ -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( - + { await renderWithEffects( wrapInTestApp( - + { query.mockReturnValueOnce(new Promise(() => {})); await renderWithEffects( wrapInTestApp( - + { query.mockResolvedValueOnce({ results: [] }); await renderWithEffects( wrapInTestApp( - + { query.mockResolvedValueOnce({ results: [] }); await renderWithEffects( wrapInTestApp( - + { query.mockRejectedValueOnce(new Error()); await renderWithEffects( wrapInTestApp( - + { ).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 => + Result: {props.result?.title}, + }), + ); + + await renderWithEffects( + wrapInTestApp( + + + + + , + ), + ); + + await waitFor(() => { + expect(screen.getByText('Result: Search Result 1')).toBeInTheDocument(); + }); + + expect(screen.getByText('Result: Search Result 2')).toBeInTheDocument(); + }); }); diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx index 4279e8936a..1a539b74a6 100644 --- a/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx @@ -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 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 => ( { result={resultItem.document} /> ), - noResultsComponent = ( + disableRenderingWithNoResults, + noResultsComponent = disableRenderingWithNoResults ? null : ( ), ...rest } = props; - return ( - - {loading ? : null} - {!loading && error ? ( - - ) : null} - {!loading && !error && resultItems?.length - ? resultItems.map(renderResultItem) - : null} - {!loading && !error && !resultItems?.length ? ( - {noResultsComponent} - ) : null} - - ); + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + if (!resultItems?.length) { + return <>{noResultsComponent}; + } + + return {resultItems.map(renderResultItem)}; }; /** * 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; - /** - * Optional property to provide if component should not render the component when no results are found. - */ - disableRenderingWithNoResults?: boolean; -}; +export type SearchResultListProps = Pick & + Omit; /** * 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 ( { }} > - {({ loading, error, value }) => { - if (!value?.results?.length && disableRenderingWithNoResults) { - return null; - } - - return ( - - ); - }} + {({ loading, error, value }) => ( + + )} ); diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index 5f68905407..c85714fa46 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -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'; diff --git a/plugins/search-react/src/extensions.test.tsx b/plugins/search-react/src/extensions.test.tsx new file mode 100644 index 0000000000..24c115e572 --- /dev/null +++ b/plugins/search-react/src/extensions.test.tsx @@ -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 }) => + ( + + + + ), + } = options; + return plugin.provide( + createSearchResultListItemExtension({ + predicate, + component, + name: 'TestSearchResultItemExtension', + }), + ); +}; + +describe('extensions', () => { + it('renders without exploding', async () => { + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + , + ), + ); + + 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( + + + + + , + ), + ); + + 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 }) => + ( + + + + ), + }); + + await renderWithEffects( + wrapInTestApp( + + + + + + , + ), + ); + + 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(); + }); +}); diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx new file mode 100644 index 0000000000..1e0619b14c --- /dev/null +++ b/plugins/search-react/src/extensions.tsx @@ -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 ( + + {children} + + ); +}; + +/** + * @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; + /** + * 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, +): Extension => { + const { name, component, predicate = () => true } = options; + + return createReactExtension({ + name, + component: { + lazy: () => + component().then( + type => + (props => ( + + {createElement(type, props)} + + )) 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 ( + + {element ?? ( + + + + )} + + ); + }, + [elements], + ); +}; + +/** + * @public + * Props for {@link SearchResultListItemExtensions} + */ +export type SearchResultListItemExtensionsProps = Omit & { + /** + * 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 {results.map(render)}; +}; diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts index 9813661f3e..8b234e3c05 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 './extensions'; export * from './components'; export { SearchContextProvider, diff --git a/plugins/search/src/components/SearchModal/SearchModal.tsx b/plugins/search/src/components/SearchModal/SearchModal.tsx index b1a0b2cd45..d17cf0f6ba 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.tsx @@ -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) => { - - {({ results }) => ( - - {results.map(({ document, highlight }) => ( -
- -
- ))} -
- )} -
+ diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 8e1db7c2b8..66b032c83c 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -47,7 +47,7 @@ export type { SidebarSearchModalProps } from './components/SidebarSearchModal'; export { HomePageSearchBar, SearchPage, + SidebarSearchModal, searchPlugin as plugin, searchPlugin, - SidebarSearchModal, } from './plugin'; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index d1c05f5b8e..ea7d86e9bc 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -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; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 13b71d49ed..c759d43fe9 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -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 * diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 4ad8b98e37..331eefe121 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -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', + }), +); diff --git a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx index 800fba2260..f058fd0a9e 100644 --- a/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx +++ b/plugins/techdocs/src/search/components/TechDocsSearchResultListItem.tsx @@ -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 ? ( - + {children} ) : ( @@ -122,6 +112,8 @@ export const TechDocsSearchResultListItem = ( result.name ); + if (!result) return null; + return (