diff --git a/.changeset/search-dull-planes-prove.md b/.changeset/search-dull-planes-prove.md new file mode 100644 index 0000000000..f185207ede --- /dev/null +++ b/.changeset/search-dull-planes-prove.md @@ -0,0 +1,30 @@ +--- +'@backstage/plugin-search-react': minor +--- + +The `` component now accepts an optional property `disableRenderingWithNoResults` to disable rendering when no results are returned. +Possibility to provide a custom no results component if needed through the `noResultsComponent` property. + +Examples: + +_Rendering a custom no results component_ + +```jsx +} + title="Documentation" + noResultsComponent={} +/> +``` + +_Disable rendering when there are no results_ + +```jsx +} + title="Documentation" + disableRenderingWithNoResults +/> +``` diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 5ec4124fcc..194b285e49 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -213,7 +213,9 @@ export type SearchFilterWrapperProps = SearchFilterComponentProps & { export const SearchResult: (props: SearchResultProps) => JSX.Element; // @public -export const SearchResultApi: (props: SearchResultApiProps) => JSX.Element; +export const SearchResultApi: ( + props: SearchResultApiProps, +) => JSX.Element | null; // @public export type SearchResultApiProps = SearchResultContextProps & { @@ -226,11 +228,11 @@ export const SearchResultComponent: (props: SearchResultProps) => JSX.Element; // @public export const SearchResultContext: ( props: SearchResultContextProps, -) => JSX.Element; +) => JSX.Element | null; // @public export type SearchResultContextProps = { - children: (state: AsyncState) => JSX.Element; + children: (state: AsyncState) => JSX.Element | null; }; // @public @@ -269,13 +271,22 @@ export type SearchResultGroupLayoutProps = ListProps & { link?: ReactNode; linkProps?: Partial; filterOptions?: FilterOption[]; - renderFilterOption?: (filterOption: FilterOption) => JSX.Element; + renderFilterOption?: ( + value: FilterOption, + index: number, + array: FilterOption[], + ) => JSX.Element | null; filterFields?: string[]; renderFilterField?: (key: string) => JSX.Element | null; resultItems?: SearchResult_2[]; - renderResultItem?: (resultItem: SearchResult_2) => JSX.Element; + renderResultItem?: ( + value: SearchResult_2, + index: number, + array: SearchResult_2[], + ) => JSX.Element | null; error?: Error; loading?: boolean; + noResultsComponent?: ReactNode; }; // @public @@ -284,6 +295,7 @@ export type SearchResultGroupProps = Omit< 'loading' | 'error' | 'resultItems' | 'filterFields' > & { query: Partial; + disableRenderingWithNoResults?: boolean; }; // @public diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.tsx index a3e4c0e1f0..40c60c3263 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.tsx @@ -36,7 +36,7 @@ export type SearchResultContextProps = { /** * A child function that receives an asynchronous result set and returns a react element. */ - children: (state: AsyncState) => JSX.Element; + children: (state: AsyncState) => JSX.Element | null; }; /** diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx index 3a20dec36d..fab807dd84 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx @@ -269,7 +269,7 @@ export const WithFilters = () => { ); }; -export const WithNoResults = () => { +export const WithDefaultNoResultsComponent = () => { const [query] = useState>({ types: ['techdocs'], }); @@ -285,6 +285,23 @@ export const WithNoResults = () => { ); }; +export const WithCustomNoResultsComponent = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + + } + title="Documentation" + noResultsComponent={} + /> + + ); +}; + const CustomResultListItem = (props: any) => { const { icon, result } = props; diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx index c1b45cc433..7943dba3f7 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx @@ -291,6 +291,46 @@ describe('SearchResultGroup', () => { }); }); + it('Does not render result group if no results returned and disableRenderingWithNoResults prop is provided', async () => { + query.mockResolvedValueOnce({ results: [] }); + await renderWithEffects( + wrapInTestApp( + + } + title="Documentation" + disableRenderingWithNoResults + /> + , + ), + ); + + await waitFor(() => { + expect(screen.queryByText('Documentation')).not.toBeInTheDocument(); + }); + }); + + it('Should render custom component when no results returned', async () => { + query.mockResolvedValueOnce({ results: [] }); + await renderWithEffects( + wrapInTestApp( + + } + title="Documentation" + noResultsComponent="No results were found" + /> + , + ), + ); + + await waitFor(() => { + expect(screen.getByText('No results were found')).toBeInTheDocument(); + }); + }); + it('Shows an error panel when results rendering fails', async () => { query.mockRejectedValueOnce(new Error()); await renderWithEffects( diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx index 9c55996ca7..6040dbe560 100644 --- a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx @@ -306,7 +306,11 @@ export type SearchResultGroupLayoutProps = ListProps & { * Function to customize how filter options are rendered. * @remarks Defaults to a menu item where its value and label bounds to the option string. */ - renderFilterOption?: (filterOption: FilterOption) => JSX.Element; + renderFilterOption?: ( + value: FilterOption, + index: number, + array: FilterOption[], + ) => JSX.Element | null; /** * A list of search filter keys, also known as filter field names. */ @@ -322,7 +326,11 @@ export type SearchResultGroupLayoutProps = ListProps & { /** * Function to customize how result items are rendered. */ - renderResultItem?: (resultItem: SearchResult) => JSX.Element; + renderResultItem?: ( + value: SearchResult, + index: number, + array: SearchResult[], + ) => JSX.Element | null; /** * If defined, will render a default error panel. */ @@ -331,6 +339,10 @@ export type SearchResultGroupLayoutProps = ListProps & { * If defined, will render a default loading progress. */ loading?: boolean; + /** + * Optional component to render when no results. Default to component. + */ + noResultsComponent?: ReactNode; }; /** @@ -350,14 +362,31 @@ export function SearchResultGroupLayout( icon, title, titleProps = {}, - link, + link = ( + <> + See all + + + ), linkProps = {}, filterOptions, - renderFilterOption, + renderFilterOption = filterOption => ( + + {filterOption} + + ), filterFields, renderFilterField, resultItems, - renderResultItem, + renderResultItem = resultItem => ( + + ), + noResultsComponent = ( + + ), ...rest } = props; @@ -401,30 +430,14 @@ export function SearchResultGroupLayout( onClick={handleClose} keepMounted > - {filterOptions.map(filterOption => - renderFilterOption ? ( - renderFilterOption(filterOption) - ) : ( - - {filterOption} - - ), - )} + {filterOptions.map(renderFilterOption)} ) : null} {filterFields?.map( filterField => renderFilterField?.(filterField) ?? null, )} - {link ?? ( - <> - See all - - - )} + {link} {loading ? : null} @@ -435,12 +448,10 @@ export function SearchResultGroupLayout( /> ) : null} {!loading && !error && resultItems?.length - ? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null) + ? resultItems.map(renderResultItem) : null} {!loading && !error && !resultItems?.length ? ( - - - + {noResultsComponent} ) : null} ); @@ -458,6 +469,10 @@ export type SearchResultGroupProps = Omit< * 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; }; /** @@ -471,9 +486,7 @@ export function SearchResultGroup( const { query, linkProps = {}, - renderResultItem = ({ document }) => ( - - ), + disableRenderingWithNoResults, ...rest } = props; @@ -495,17 +508,22 @@ export function SearchResultGroup( }} > - {({ loading, error, value }) => ( - - )} + {({ loading, error, value }) => { + if (!value?.results?.length && disableRenderingWithNoResults) { + return null; + } + + return ( + + ); + }} );