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';