From a7ec5e7d78264924b152ca048bddefc79185c617 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sun, 22 Jan 2023 19:33:25 +0100 Subject: [PATCH] feat(search): create result item hook and components Signed-off-by: Camila Belo --- .../SearchResult/SearchResult.stories.tsx | 23 ++- .../SearchResult/SearchResult.test.tsx | 106 ++++++++++- .../components/SearchResult/SearchResult.tsx | 44 +++-- .../src/components/SearchResult/index.tsx | 2 +- .../SearchResultGroup.stories.tsx | 34 +++- .../SearchResultGroup.test.tsx | 174 ++++++++++++++++-- .../SearchResultGroup/SearchResultGroup.tsx | 122 ++++++------ .../SearchResultList.stories.tsx | 32 +++- .../SearchResultList.test.tsx | 89 ++++++++- .../SearchResultList/SearchResultList.tsx | 112 ++++++----- plugins/search-react/src/components/index.ts | 4 +- 11 files changed, 570 insertions(+), 172 deletions(-) diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx index ef9b0d6b5c..b491d66c83 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx @@ -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 = () => { ); }; + +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';