feat(search): create result item hook and components

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-01-22 19:33:25 +01:00
parent 098cf0f8c0
commit a7ec5e7d78
11 changed files with 570 additions and 172 deletions
@@ -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 = () => {
</SearchResult>
);
};
export const UsingSearchResultItemExtensions = () => {
const plugin = createPlugin({ id: 'plugin' });
const DefaultResultItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResult>
<DefaultResultItem />
</SearchResult>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchResult query={{ types: ['techdocs'] }}>
{value => {
expect(value.results).toStrictEqual(results);
return <></>;
}}
</SearchResult>
</TestApiProvider>,
),
);
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(<SearchResult />);
expect(getByText('some-title')).toBeInTheDocument();
const SearchResultExtension = createPlugin({
id: 'plugin',
}).provide(
createSearchResultListItemExtension({
name: 'SearchResultExtension',
component: async () => props =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
rerender(
<SearchResult>
<SearchResultExtension />
</SearchResult>,
);
await waitFor(() => {
expect(getByText('Result: some-title')).toBeInTheDocument();
});
});
});
@@ -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<SearchResultSet>) => JSX.Element | null;
children: (
state: AsyncState<SearchResultSet>,
query: Partial<SearchQuery>,
) => 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<SearchResultStateProps, 'query'> & {
children: (resultSet: SearchResultSet) => JSX.Element;
noResultsComponent?: JSX.Element;
};
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListItemExtensionsProps, 'results' | 'children'> & {
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 = (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
} = props;
return (
@@ -205,7 +215,15 @@ export const SearchResultComponent = (props: SearchResultProps) => {
return noResultsComponent;
}
return children(value);
if (isFunction(children)) {
return children(value);
}
return (
<SearchResultListItemExtensions {...rest} results={value.results}>
{children}
</SearchResultListItemExtensions>
);
}}
</SearchResultState>
);
@@ -18,8 +18,8 @@ export {
SearchResult,
SearchResultApi,
SearchResultContext,
SearchResultState,
SearchResultComponent,
SearchResultState,
} from './SearchResult';
export type {
@@ -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 (
<SearchContextProvider>
<SearchResultGroup icon={<DocsIcon />} title="Documentation" />
</SearchContextProvider>
);
};
export const WithQuery = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
@@ -341,3 +353,21 @@ export const WithCustomResultItem = () => {
/>
);
};
export const WithResultItemExtensions = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultGroupItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResultGroup query={query} icon={<DocsIcon />} title="Documentation">
<DefaultSearchResultGroupItem />
</SearchResultGroup>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchContextProvider>
<SearchResultGroup
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</SearchContextProvider>
</TestApiProvider>,
),
);
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 =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
>
<SearchResultGroupItemExtension />
</SearchResultGroup>
</TestApiProvider>,
),
);
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(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -108,7 +207,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -132,7 +236,12 @@ describe('SearchResultGroup', () => {
it('Could be customized with no results text', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -154,7 +263,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -184,7 +298,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{
types: ['techdocs'],
@@ -232,7 +351,12 @@ describe('SearchResultGroup', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{
types: ['techdocs'],
@@ -276,7 +400,12 @@ describe('SearchResultGroup', () => {
query.mockReturnValueOnce(new Promise(() => {}));
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -295,7 +424,12 @@ describe('SearchResultGroup', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -315,7 +449,12 @@ describe('SearchResultGroup', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -335,7 +474,12 @@ describe('SearchResultGroup', () => {
query.mockRejectedValueOnce(new Error());
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
@@ -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<FilterOption> = 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<FilterOption> = 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 <EmptyState /> 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<FilterOption>(
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
loading,
error,
loading,
icon,
title,
titleProps = {},
@@ -384,7 +389,8 @@ export function SearchResultGroupLayout<FilterOption>(
result={resultItem.document}
/>
),
noResultsComponent = (
disableRenderingWithNoResults,
noResultsComponent = disableRenderingWithNoResults ? null : (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
@@ -398,6 +404,23 @@ export function SearchResultGroupLayout<FilterOption>(
setAnchorEl(null);
}, []);
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!resultItems?.length) {
return <>{noResultsComponent}</>;
}
return (
<List {...rest}>
<ListSubheader className={classes.listSubheader}>
@@ -440,19 +463,7 @@ export function SearchResultGroupLayout<FilterOption>(
{link}
</Link>
</ListSubheader>
{loading ? <Progress /> : null}
{!loading && error ? (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(renderResultItem)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>{noResultsComponent}</ListItem>
) : null}
{resultItems.map(renderResultItem)}
</List>
);
}
@@ -461,19 +472,14 @@ export function SearchResultGroupLayout<FilterOption>(
* Props for {@link SearchResultGroup}.
* @public
*/
export type SearchResultGroupProps<FilterOption> = Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
> & {
/**
* 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;
};
export type SearchResultGroupProps<FilterOption> = Pick<
SearchResultStateProps,
'query'
> &
Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
>;
/**
* Given a query, search for results and render them as a group.
@@ -483,22 +489,9 @@ export type SearchResultGroupProps<FilterOption> = Omit<
export function SearchResultGroup<FilterOption>(
props: SearchResultGroupProps<FilterOption>,
) {
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 (
<AnalyticsContext
@@ -508,19 +501,24 @@ export function SearchResultGroup<FilterOption>(
}}
>
<SearchResultState query={query}>
{({ 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 (
<SearchResultGroupLayout
{...rest}
loading={loading}
error={error}
loading={loading}
linkProps={{ to, ...linkProps }}
filterFields={Object.keys(filters)}
resultItems={value?.results}
filterFields={Object.keys(query.filters ?? {})}
renderResultItem={renderResultItem ?? defaultRenderResultItem}
/>
);
}}
@@ -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 (
<SearchContextProvider>
<SearchResultList />
</SearchContextProvider>
);
};
export const WithQuery = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
@@ -195,3 +205,21 @@ export const WithCustomResultItem = () => {
/>
);
};
export const WithResultItemExtensions = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
const plugin = createPlugin({ id: 'plugin' });
const DefaultSearchResultListItem = plugin.provide(
createSearchResultListItemExtension({
name: 'DefaultResultListItem',
component: async () => DefaultResultListItem,
}),
);
return (
<SearchResultList query={query}>
<DefaultSearchResultListItem />
</SearchResultList>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -81,7 +92,12 @@ describe('SearchResultList', () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -106,7 +122,12 @@ describe('SearchResultList', () => {
query.mockReturnValueOnce(new Promise(() => {}));
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -125,7 +146,12 @@ describe('SearchResultList', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{ types: ['techdocs'] }}
disableRenderingWithNoResults
@@ -143,7 +169,12 @@ describe('SearchResultList', () => {
query.mockResolvedValueOnce({ results: [] });
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{ types: ['techdocs'] }}
noResultsComponent="No results were found"
@@ -161,7 +192,12 @@ describe('SearchResultList', () => {
query.mockRejectedValueOnce(new Error());
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
@@ -179,4 +215,45 @@ describe('SearchResultList', () => {
).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 =>
<ListItem>Result: {props.result?.title}</ListItem>,
}),
);
await renderWithEffects(
wrapInTestApp(
<TestApiProvider
apis={[
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchResultList
query={{
types: ['techdocs'],
}}
>
<SearchResultListItemExtension />
</SearchResultList>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(screen.getByText('Result: Search Result 1')).toBeInTheDocument();
});
expect(screen.getByText('Result: Search Result 2')).toBeInTheDocument();
});
});
@@ -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 <EmptyState /> 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 => (
<DefaultResultListItem
@@ -76,48 +82,39 @@ export const SearchResultListLayout = (props: SearchResultListLayoutProps) => {
result={resultItem.document}
/>
),
noResultsComponent = (
disableRenderingWithNoResults,
noResultsComponent = disableRenderingWithNoResults ? null : (
<EmptyState missing="data" title="Sorry, no results were found" />
),
...rest
} = props;
return (
<List {...rest}>
{loading ? <Progress /> : null}
{!loading && error ? (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(renderResultItem)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>{noResultsComponent}</ListItem>
) : null}
</List>
);
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!resultItems?.length) {
return <>{noResultsComponent}</>;
}
return <List {...rest}>{resultItems.map(renderResultItem)}</List>;
};
/**
* 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<SearchQuery>;
/**
* Optional property to provide if component should not render the component when no results are found.
*/
disableRenderingWithNoResults?: boolean;
};
export type SearchResultListProps = Pick<SearchResultStateProps, 'query'> &
Omit<SearchResultListLayoutProps, 'loading' | 'error' | 'resultItems'>;
/**
* 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 (
<AnalyticsContext
@@ -135,20 +134,15 @@ export const SearchResultList = (props: SearchResultListProps) => {
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => {
if (!value?.results?.length && disableRenderingWithNoResults) {
return null;
}
return (
<SearchResultListLayout
{...rest}
loading={loading}
error={error}
resultItems={value?.results}
/>
);
}}
{({ loading, error, value }) => (
<SearchResultListLayout
{...rest}
error={error}
loading={loading}
resultItems={value?.results}
renderResultItem={renderResultItem ?? defaultRenderResultItem}
/>
)}
</SearchResultState>
</AnalyticsContext>
);
+2 -2
View File
@@ -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';