Merge pull request #13945 from backstage/bux/search-group-empty-state
[Search] Customize <SearchResultGroup/> no results state
This commit is contained in:
@@ -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
|
||||
/>
|
||||
```
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+18
-1
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user