Merge pull request #13945 from backstage/bux/search-group-empty-state

[Search] Customize <SearchResultGroup/> no results state
This commit is contained in:
Emma Indal
2022-10-04 09:57:36 +02:00
committed by GitHub
6 changed files with 165 additions and 48 deletions
+30
View File
@@ -0,0 +1,30 @@
---
'@backstage/plugin-search-react': minor
---
The `<SearchResultGroup />` 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
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
noResultsComponent={<ListItemText primary="No results were found" />}
/>
```
_Disable rendering when there are no results_
```jsx
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
disableRenderingWithNoResults
/>
```
+17 -5
View File
@@ -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<SearchResultSet>) => JSX.Element;
children: (state: AsyncState<SearchResultSet>) => JSX.Element | null;
};
// @public
@@ -269,13 +271,22 @@ export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
link?: ReactNode;
linkProps?: Partial<LinkProps>;
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<FilterOption> = Omit<
'loading' | 'error' | 'resultItems' | 'filterFields'
> & {
query: Partial<SearchQuery>;
disableRenderingWithNoResults?: boolean;
};
// @public
@@ -36,7 +36,7 @@ export type SearchResultContextProps = {
/**
* A child function that receives an asynchronous result set and returns a react element.
*/
children: (state: AsyncState<SearchResultSet>) => JSX.Element;
children: (state: AsyncState<SearchResultSet>) => JSX.Element | null;
};
/**
@@ -269,7 +269,7 @@ export const WithFilters = () => {
);
};
export const WithNoResults = () => {
export const WithDefaultNoResultsComponent = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
@@ -285,6 +285,23 @@ export const WithNoResults = () => {
);
};
export const WithCustomNoResultsComponent = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
noResultsComponent={<ListItemText primary="No results were found" />}
/>
</TestApiProvider>
);
};
const CustomResultListItem = (props: any) => {
const { icon, result } = props;
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
disableRenderingWithNoResults
/>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(screen.queryByText('Documentation')).not.toBeInTheDocument();
});
});
it('Should render custom component when no results returned', async () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
noResultsComponent="No results were found"
/>
</TestApiProvider>,
),
);
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(
@@ -306,7 +306,11 @@ export type SearchResultGroupLayoutProps<FilterOption> = 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<FilterOption> = 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<FilterOption> = ListProps & {
* If defined, will render a default loading progress.
*/
loading?: boolean;
/**
* Optional component to render when no results. Default to <EmptyState /> component.
*/
noResultsComponent?: ReactNode;
};
/**
@@ -350,14 +362,31 @@ export function SearchResultGroupLayout<FilterOption>(
icon,
title,
titleProps = {},
link,
link = (
<>
See all
<ArrowRightIcon className={classes.listSubheaderLinkIcon} />
</>
),
linkProps = {},
filterOptions,
renderFilterOption,
renderFilterOption = filterOption => (
<MenuItem key={String(filterOption)} value={String(filterOption)}>
{filterOption}
</MenuItem>
),
filterFields,
renderFilterField,
resultItems,
renderResultItem,
renderResultItem = resultItem => (
<DefaultResultListItem
key={resultItem.document.location}
result={resultItem.document}
/>
),
noResultsComponent = (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
} = props;
@@ -401,30 +430,14 @@ export function SearchResultGroupLayout<FilterOption>(
onClick={handleClose}
keepMounted
>
{filterOptions.map(filterOption =>
renderFilterOption ? (
renderFilterOption(filterOption)
) : (
<MenuItem
key={String(filterOption)}
value={String(filterOption)}
>
{filterOption}
</MenuItem>
),
)}
{filterOptions.map(renderFilterOption)}
</Menu>
) : null}
{filterFields?.map(
filterField => renderFilterField?.(filterField) ?? null,
)}
<Link className={classes.listSubheaderLink} to="/search" {...linkProps}>
{link ?? (
<>
See all
<ArrowRightIcon className={classes.listSubheaderLinkIcon} />
</>
)}
{link}
</Link>
</ListSubheader>
{loading ? <Progress /> : null}
@@ -435,12 +448,10 @@ export function SearchResultGroupLayout<FilterOption>(
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null)
? resultItems.map(renderResultItem)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>
<EmptyState missing="data" title="Sorry, no results were found" />
</ListItem>
<ListItem>{noResultsComponent}</ListItem>
) : null}
</List>
);
@@ -458,6 +469,10 @@ export type SearchResultGroupProps<FilterOption> = Omit<
* A search query used for requesting the results to be grouped.
*/
query: Partial<SearchQuery>;
/**
* Optional property to provide if component should not render the group when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
/**
@@ -471,9 +486,7 @@ export function SearchResultGroup<FilterOption>(
const {
query,
linkProps = {},
renderResultItem = ({ document }) => (
<DefaultResultListItem key={document.location} result={document} />
),
disableRenderingWithNoResults,
...rest
} = props;
@@ -495,17 +508,22 @@ export function SearchResultGroup<FilterOption>(
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => (
<SearchResultGroupLayout
{...rest}
loading={loading}
error={error}
linkProps={{ to, ...linkProps }}
resultItems={value?.results}
renderResultItem={renderResultItem}
filterFields={Object.keys(query.filters ?? {})}
/>
)}
{({ loading, error, value }) => {
if (!value?.results?.length && disableRenderingWithNoResults) {
return null;
}
return (
<SearchResultGroupLayout
{...rest}
loading={loading}
error={error}
linkProps={{ to, ...linkProps }}
resultItems={value?.results}
filterFields={Object.keys(query.filters ?? {})}
/>
);
}}
</SearchResultState>
</AnalyticsContext>
);