diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
index 2fde91a1f8..8ac8aace0f 100644
--- a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
+++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
@@ -27,6 +27,7 @@ import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { DefaultResultListItem } from '../DefaultResultListItem';
+import { SearchResultListLayout } from '../SearchResultList';
import { SearchResult } from './SearchResult';
@@ -149,3 +150,33 @@ export const WithQuery = () => {
);
};
+
+export const ListLayout = () => {
+ return (
+
+ {({ results }) => (
+ {
+ switch (type) {
+ case 'custom-result-item':
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ }}
+ />
+ )}
+
+ );
+};
diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx
new file mode 100644
index 0000000000..5762aba2d8
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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 { searchApiRef, MockSearchApi } from '../../api';
+
+import { SearchResultList } from './SearchResultList';
+import { DefaultResultListItem } from '../DefaultResultListItem';
+
+const routeRef = createRouteRef({
+ id: 'storybook.search.results.list.route',
+});
+
+const searchApiMock = new MockSearchApi({
+ results: [
+ {
+ type: 'techdocs',
+ document: {
+ location: 'search/search-result1',
+ title: 'Search Result 1',
+ text: 'Some text from the search result 1',
+ },
+ },
+ {
+ type: 'custom',
+ document: {
+ location: 'search/search-result2',
+ title: 'Search Result 2',
+ text: 'Some text from the search result 2',
+ },
+ },
+ ],
+});
+
+export default {
+ title: 'Plugins/Search/SearchResultList',
+ component: SearchResultList,
+ decorators: [
+ (Story: ComponentType<{}>) =>
+ wrapInTestApp(
+
+
+
+
+
+
+ ,
+ { mountedRoutes: { '/': routeRef } },
+ ),
+ ],
+};
+
+export const Default = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return ;
+};
+
+export const Loading = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+ new Promise(() => {}) }],
+ ]}
+ >
+
+
+ );
+};
+
+export const WithError = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+
+ new Promise(() => {
+ throw new Error();
+ }),
+ },
+ ],
+ ]}
+ >
+
+
+ );
+};
+
+export const WithNoResults = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+
+
+
+ );
+};
+
+const CustomResultListItem = (props: any) => {
+ const { icon, result } = props;
+
+ return (
+
+
+ {icon && {icon}}
+
+
+
+ );
+};
+
+export const WithCustomResultItem = () => {
+ const [query] = useState>({
+ types: ['custom'],
+ });
+
+ return (
+ {
+ switch (type) {
+ case 'custom':
+ return (
+ }
+ result={document}
+ highlight={highlight}
+ rank={rank}
+ />
+ );
+ default:
+ return (
+
+ );
+ }
+ }}
+ />
+ );
+};
diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx
new file mode 100644
index 0000000000..20e5a1cba1
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { screen, waitFor } from '@testing-library/react';
+
+import {
+ TestApiProvider,
+ renderWithEffects,
+ wrapInTestApp,
+} from '@backstage/test-utils';
+
+import { searchApiRef } from '../../api';
+import { SearchResultList } from './SearchResultList';
+
+const query = jest.fn().mockResolvedValue({ results: [] });
+const searchApiMock = { query };
+
+describe('SearchResultList', () => {
+ const results = [
+ {
+ type: 'techdocs',
+ document: {
+ location: 'search/search-result1',
+ title: 'Search Result 1',
+ text: 'Some text from the search result 1',
+ },
+ },
+ {
+ type: 'techdocs',
+ document: {
+ location: 'search/search-result2',
+ title: 'Search Result 2',
+ text: 'Some text from the search result 2',
+ },
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('Renders without exploding', async () => {
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ expect(query).toHaveBeenCalledWith({
+ filters: {},
+ pageCursor: undefined,
+ term: '',
+ types: ['techdocs'],
+ });
+ });
+
+ it('Defines a default render result item', async () => {
+ query.mockResolvedValueOnce({
+ results,
+ });
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ 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('Shows a progress bar when loading results', async () => {
+ query.mockReturnValueOnce(new Promise(() => {}));
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+ });
+
+ it('Shows an error panel when results rendering fails', async () => {
+ query.mockRejectedValueOnce(new Error());
+ await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'Error: Error encountered while fetching search results',
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx
new file mode 100644
index 0000000000..8f184f7080
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { List, ListProps } from '@material-ui/core';
+
+import {
+ EmptyState,
+ Progress,
+ ResponseErrorPanel,
+} from '@backstage/core-components';
+import { AnalyticsContext } from '@backstage/core-plugin-api';
+import { SearchQuery, SearchResult } from '@backstage/plugin-search-common';
+
+import { DefaultResultListItem } from '../DefaultResultListItem';
+import { SearchResultState } from '../SearchResult';
+
+/**
+ * Props for {@link SearchResultListLayout}
+ * @public
+ */
+export type SearchResultListLayoutProps = ListProps & {
+ /**
+ * Search results to be rendered as a list.
+ */
+ resultItems?: SearchResult[];
+ /**
+ * Function to customize how result items are rendered.
+ */
+ renderResultItem?: (resultItem: SearchResult) => JSX.Element;
+ /**
+ * If defined, will render a default error panel.
+ */
+ error?: Error;
+ /**
+ * If defined, will render a default loading progress.
+ */
+ loading?: boolean;
+};
+
+/**
+ * Default layout for rendering search results in a list.
+ * @param props - See {@link SearchResultListLayoutProps}.
+ * @public
+ */
+export const SearchResultListLayout = (props: SearchResultListLayoutProps) => {
+ const { loading, error, resultItems, renderResultItem, ...rest } = props;
+
+ return (
+
+ {loading ? : null}
+ {!loading && error ? (
+
+ ) : null}
+ {!loading && !error && resultItems?.length
+ ? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null)
+ : null}
+ {!loading && !error && !resultItems?.length ? (
+
+ ) : null}
+
+ );
+};
+
+/**
+ * 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;
+};
+
+/**
+ * Given a query, search for results and render them as a list.
+ * @param props - See {@link SearchResultListProps}.
+ * @public
+ */
+export const SearchResultList = (props: SearchResultListProps) => {
+ const {
+ query,
+ renderResultItem = ({ document }) => (
+
+ ),
+ ...rest
+ } = props;
+
+ return (
+
+
+ {({ loading, error, value }) => (
+
+ )}
+
+
+ );
+};
diff --git a/plugins/search-react/src/components/SearchResultList/index.tsx b/plugins/search-react/src/components/SearchResultList/index.tsx
new file mode 100644
index 0000000000..64dee0dd2c
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultList/index.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { SearchResultList, SearchResultListLayout } from './SearchResultList';
+
+export type {
+ SearchResultListProps,
+ SearchResultListLayoutProps,
+} from './SearchResultList';
diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts
index e254c36dd9..6cf7a22a7d 100644
--- a/plugins/search-react/src/components/index.ts
+++ b/plugins/search-react/src/components/index.ts
@@ -20,4 +20,5 @@ export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
+export * from './SearchResultList';
export * from './DefaultResultListItem';