diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
index 8ac8aace0f..4b8d9bbdd4 100644
--- a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
+++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx
@@ -18,6 +18,8 @@ import React, { ComponentType } from 'react';
import { MemoryRouter } from 'react-router';
import { List, ListItem } from '@material-ui/core';
+import DefaultIcon from '@material-ui/icons/InsertDriveFile';
+import CustomIcon from '@material-ui/icons/NoteAdd';
import { Link } from '@backstage/core-components';
import { TestApiProvider } from '@backstage/test-utils';
@@ -30,6 +32,7 @@ import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResultListLayout } from '../SearchResultList';
import { SearchResult } from './SearchResult';
+import { SearchResultGroupLayout } from '../SearchResultGroup';
const mockResults = {
results: [
@@ -180,3 +183,38 @@ export const ListLayout = () => {
);
};
+
+export const GroupLayout = () => {
+ return (
+
+ {({ results }) => (
+ <>
+ }
+ title="Custom"
+ link="See all custom results"
+ resultItems={results.filter(
+ ({ type }) => type === 'custom-result-item',
+ )}
+ renderResultItem={({ document }) => (
+
+ )}
+ />
+ }
+ title="Default"
+ resultItems={results.filter(
+ ({ type }) => type !== 'custom-result-item',
+ )}
+ renderResultItem={({ document }) => (
+
+ )}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx
new file mode 100644
index 0000000000..3a20dec36d
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx
@@ -0,0 +1,326 @@
+/*
+ * 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, useCallback, useState } from 'react';
+
+import {
+ Grid,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ MenuItem,
+} from '@material-ui/core';
+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 { searchApiRef, MockSearchApi } from '../../api';
+
+import {
+ SearchResultGroup,
+ SearchResultGroupTextFilterField,
+ SearchResultGroupSelectFilterField,
+} from './SearchResultGroup';
+
+const routeRef = createRouteRef({
+ id: 'storybook.search.results.group.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/SearchResultGroup',
+ component: SearchResultGroup,
+ decorators: [
+ (Story: ComponentType<{}>) =>
+ wrapInTestApp(
+
+
+
+
+
+
+ ,
+ { mountedRoutes: { '/': routeRef } },
+ ),
+ ],
+};
+
+export const Default = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+ }
+ title="Documentation"
+ />
+ );
+};
+
+export const Loading = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+ new Promise(() => {}) }],
+ ]}
+ >
+ }
+ title="Documentation"
+ />
+
+ );
+};
+
+export const WithError = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+
+ new Promise(() => {
+ throw new Error();
+ }),
+ },
+ ],
+ ]}
+ >
+ }
+ title="Documentation"
+ />
+
+ );
+};
+
+export const WithCustomTitle = () => {
+ const [query] = useState>({
+ types: ['custom'],
+ });
+
+ return (
+ }
+ title="Custom"
+ titleProps={{ color: 'secondary' }}
+ />
+ );
+};
+
+export const WithCustomLink = () => {
+ const [query] = useState>({
+ types: ['custom'],
+ });
+
+ return (
+ }
+ title="Custom"
+ link="See all custom results"
+ linkProps={{ to: '/custom' }}
+ />
+ );
+};
+
+export const WithFilters = () => {
+ const [query, setQuery] = useState>({
+ types: ['software-catalog'],
+ });
+
+ const filterOptions = [
+ {
+ label: 'Lifecycle',
+ value: 'lifecycle',
+ },
+ {
+ label: 'Owner',
+ value: 'owner',
+ },
+ ];
+
+ const handleFilterAdd = useCallback(
+ (key: string) => () => {
+ setQuery(prevQuery => {
+ const { filters: prevFilters, ...rest } = prevQuery;
+ const newFilters = { ...prevFilters, [key]: undefined };
+ return { ...rest, filters: newFilters };
+ });
+ },
+ [],
+ );
+
+ const handleFilterChange = useCallback(
+ (key: string) => (value: JsonValue) => {
+ setQuery(prevQuery => {
+ const { filters: prevFilters, ...rest } = prevQuery;
+ const newFilters = { ...prevFilters, [key]: value };
+ return { ...rest, filters: newFilters };
+ });
+ },
+ [],
+ );
+
+ const handleFilterDelete = useCallback(
+ (key: string) => () => {
+ setQuery(prevQuery => {
+ const { filters: prevFilters, ...rest } = prevQuery;
+ const newFilters = { ...prevFilters };
+ delete newFilters[key];
+ return { ...rest, filters: newFilters };
+ });
+ },
+ [],
+ );
+
+ return (
+ }
+ title="Documentation"
+ filterOptions={filterOptions}
+ renderFilterOption={option => (
+
+ )}
+ renderFilterField={(key: string) => {
+ switch (key) {
+ case 'lifecycle':
+ return (
+
+
+
+
+ );
+ case 'owner':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }}
+ />
+ );
+};
+
+export const WithNoResults = () => {
+ const [query] = useState>({
+ types: ['techdocs'],
+ });
+
+ return (
+
+ }
+ title="Documentation"
+ />
+
+ );
+};
+
+const CustomResultListItem = (props: any) => {
+ const { icon, result } = props;
+
+ return (
+
+
+ {icon && {icon}}
+
+
+
+ );
+};
+
+export const WithCustomResultItem = () => {
+ const [query] = useState>({
+ types: ['custom'],
+ });
+
+ return (
+ }
+ title="Custom"
+ link="See all custom results"
+ renderResultItem={({ document, highlight, rank }) => (
+
+ )}
+ />
+ );
+};
diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx
new file mode 100644
index 0000000000..c1b45cc433
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx
@@ -0,0 +1,316 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+
+import { MenuItem } from '@material-ui/core';
+import DocsIcon from '@material-ui/icons/InsertDriveFile';
+
+import {
+ TestApiProvider,
+ renderWithEffects,
+ wrapInTestApp,
+} from '@backstage/test-utils';
+
+import { searchApiRef } from '../../api';
+import {
+ SearchResultGroup,
+ SearchResultGroupSelectFilterField,
+ SearchResultGroupTextFilterField,
+} from './SearchResultGroup';
+
+const query = jest.fn().mockResolvedValue({ results: [] });
+const searchApiMock = { query };
+
+describe('SearchResultGroup', () => {
+ 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(
+
+ }
+ title="Documentation"
+ />
+ ,
+ ),
+ );
+
+ expect(screen.getByTitle('Docs icon')).toBeInTheDocument();
+ expect(screen.getByText('Documentation')).toBeInTheDocument();
+ expect(query).toHaveBeenCalledWith({
+ filters: {},
+ pageCursor: undefined,
+ term: '',
+ types: ['techdocs'],
+ });
+ });
+
+ it('Defines a default link', async () => {
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ />
+ ,
+ ),
+ );
+
+ const link = screen.getByText('See all', { exact: false });
+ expect(link).toHaveAttribute('href', encodeURI('/search?types[]=techdocs'));
+ });
+
+ it('Defines a default render result item', 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('Could be customized with no results text', async () => {
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ />
+ ,
+ ),
+ );
+
+ expect(
+ screen.getByText('Sorry, no results were found'),
+ ).toBeInTheDocument();
+ });
+
+ it('Could be customized with filters', async () => {
+ query.mockResolvedValueOnce({
+ results,
+ });
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ filterOptions={['lifecycle', 'owner']}
+ />
+ ,
+ ),
+ );
+
+ await userEvent.click(screen.getByText('Add filter', { exact: false }));
+
+ await waitFor(() => {
+ expect(screen.getByText('lifecycle')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('owner')).toBeInTheDocument();
+ });
+
+ it('Could have a text search filter field', async () => {
+ query.mockResolvedValueOnce({
+ results,
+ });
+
+ const handleFilterChange = jest.fn();
+ const handleFilterDelete = jest.fn();
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ filterOptions={['owner']}
+ renderFilterField={(key: string) =>
+ key === 'owner' ? (
+
+ ) : null
+ }
+ />
+ ,
+ ),
+ );
+
+ await userEvent.click(screen.getByText('Add filter', { exact: false }));
+
+ await userEvent.click(screen.getByText('owner'));
+
+ await userEvent.type(
+ screen.getByRole('textbox'),
+ '{backspace}{backspace}{backspace}{backspace}techdocs-core',
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('techdocs-core')).toBeInTheDocument();
+ });
+ });
+
+ it('Could have a select search filter field', async () => {
+ query.mockResolvedValueOnce({
+ results,
+ });
+
+ const handleFilterChange = jest.fn();
+ const handleFilterDelete = jest.fn();
+
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ filterOptions={['lifecycle']}
+ renderFilterField={(key: string) =>
+ key === 'lifecycle' ? (
+
+
+
+
+ ) : null
+ }
+ />
+ ,
+ ),
+ );
+
+ await userEvent.click(screen.getByText('Add filter', { exact: false }));
+
+ await userEvent.click(screen.getByText('lifecycle'));
+
+ await userEvent.click(screen.getByText('None'));
+
+ await userEvent.click(screen.getByText('Experimental'));
+
+ await waitFor(() => {
+ expect(handleFilterChange).toHaveBeenCalledWith('experimental');
+ });
+ });
+
+ it('Shows a progress bar when loading results', async () => {
+ query.mockReturnValueOnce(new Promise(() => {}));
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ />
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+ });
+
+ it('Shows an error panel when results rendering fails', async () => {
+ query.mockRejectedValueOnce(new Error());
+ await renderWithEffects(
+ wrapInTestApp(
+
+ }
+ title="Documentation"
+ />
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'Error: Error encountered while fetching search results',
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx
new file mode 100644
index 0000000000..9c55996ca7
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx
@@ -0,0 +1,512 @@
+/*
+ * 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, {
+ ChangeEvent,
+ PropsWithChildren,
+ ReactNode,
+ useCallback,
+ useState,
+} from 'react';
+import qs from 'qs';
+
+import {
+ makeStyles,
+ Theme,
+ List,
+ ListSubheader,
+ ListItem,
+ ListProps,
+ Menu,
+ MenuItem,
+ InputBase,
+ Select,
+ Chip,
+ Typography,
+ TypographyProps,
+} from '@material-ui/core';
+import AddIcon from '@material-ui/icons/Add';
+import ArrowRightIcon from '@material-ui/icons/ArrowForwardIos';
+
+import { JsonValue } from '@backstage/types';
+import {
+ EmptyState,
+ Link,
+ LinkProps,
+ 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';
+
+const useStyles = makeStyles((theme: Theme) => ({
+ listSubheader: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ listSubheaderName: {
+ marginLeft: theme.spacing(1),
+ textTransform: 'uppercase',
+ },
+ listSubheaderChip: {
+ color: theme.palette.text.secondary,
+ margin: theme.spacing(0, 0, 0, 1.5),
+ },
+ listSubheaderFilter: {
+ display: 'flex',
+ color: theme.palette.text.secondary,
+ margin: theme.spacing(0, 0, 0, 1.5),
+ },
+ listSubheaderLink: {
+ marginLeft: 'auto',
+ display: 'flex',
+ alignItems: 'center',
+ },
+ listSubheaderLinkIcon: {
+ fontSize: 'inherit',
+ marginLeft: theme.spacing(0.5),
+ },
+}));
+
+/**
+ * Props for {@link SearchResultGroupFilterFieldLayout}
+ * @public
+ */
+export type SearchResultGroupFilterFieldLayoutProps = PropsWithChildren<{
+ label: string;
+ value?: JsonValue;
+ onDelete: () => void;
+}>;
+
+/**
+ * Default layout for a search group filter field.
+ * @param props - See {@link SearchResultGroupFilterFieldLayoutProps}.
+ * @public
+ */
+export const SearchResultGroupFilterFieldLayout = (
+ props: SearchResultGroupFilterFieldLayoutProps,
+) => {
+ const classes = useStyles();
+ const { label, children, ...rest } = props;
+
+ return (
+
+ {label}: {children}
+ >
+ }
+ />
+ );
+};
+
+const NullIcon = () => null;
+
+/**
+ * Common props for a result group filter field.
+ * @public
+ */
+export type SearchResultGroupFilterFieldPropsWith = T &
+ SearchResultGroupFilterFieldLayoutProps & {
+ onChange: (value: JsonValue) => void;
+ };
+
+const useSearchResultGroupTextFilterStyles = makeStyles((theme: Theme) => ({
+ root: {
+ fontSize: 'inherit',
+ '&:focus': {
+ outline: 'none',
+ background: theme.palette.common.white,
+ },
+ '&:not(:focus)': {
+ cursor: 'pointer',
+ color: theme.palette.primary.main,
+ '&:hover': {
+ textDecoration: 'underline',
+ },
+ },
+ },
+}));
+
+/**
+ * Props for {@link SearchResultGroupTextFilterField}.
+ * @public
+ */
+export type SearchResultGroupTextFilterFieldProps =
+ SearchResultGroupFilterFieldPropsWith<{}>;
+
+/**
+ * A text field that can be used as filter on search result groups.
+ * @param props - See {@link SearchResultGroupTextFilterFieldProps}.
+ * @example
+ * ```
+ *
+ * ```
+ * @public
+ */
+export const SearchResultGroupTextFilterField = (
+ props: SearchResultGroupTextFilterFieldProps,
+) => {
+ const classes = useSearchResultGroupTextFilterStyles();
+ const { label, value = 'None', onChange, onDelete } = props;
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ onChange(e.target.value);
+ },
+ [onChange],
+ );
+
+ return (
+
+
+ {value}
+
+
+ );
+};
+
+const useSearchResultGroupSelectFilterStyles = makeStyles((theme: Theme) => ({
+ root: {
+ fontSize: 'inherit',
+ '&:not(:focus)': {
+ cursor: 'pointer',
+ color: theme.palette.primary.main,
+ '&:hover': {
+ textDecoration: 'underline',
+ },
+ },
+ '&:focus': {
+ outline: 'none',
+ },
+ '&>div:first-child': {
+ padding: 0,
+ },
+ },
+}));
+
+/**
+ * Props for {@link SearchResultGroupTextFilterField}.
+ * @public
+ */
+export type SearchResultGroupSelectFilterFieldProps =
+ SearchResultGroupFilterFieldPropsWith<{
+ children: ReactNode;
+ }>;
+
+/**
+ * A select field that can be used as filter on search result groups.
+ * @param props - See {@link SearchResultGroupSelectFilterFieldProps}.
+ * @example
+ * ```
+ *
+ *
+ *
+ *
+ * ```
+ * @public
+ */
+export const SearchResultGroupSelectFilterField = (
+ props: SearchResultGroupSelectFilterFieldProps,
+) => {
+ const classes = useSearchResultGroupSelectFilterStyles();
+ const { label, value = 'none', onChange, onDelete, children } = props;
+
+ const handleChange = useCallback(
+ (e: ChangeEvent<{ value: unknown }>) => {
+ onChange(e.target.value as JsonValue);
+ },
+ [onChange],
+ );
+
+ return (
+
+ }
+ IconComponent={NullIcon}
+ >
+
+ {children}
+
+
+ );
+};
+
+/**
+ * Props for {@link SearchResultGroupLayout}
+ * @public
+ */
+export type SearchResultGroupLayoutProps = ListProps & {
+ /**
+ * Icon that representing a result group.
+ */
+ icon: JSX.Element;
+ /**
+ * The results group title content, it could be a text or an element.
+ */
+ title: ReactNode;
+ /**
+ * Props for the results group title.
+ */
+ titleProps?: Partial;
+ /**
+ * The results group link content, it could be a text or an element.
+ */
+ link?: ReactNode;
+ /**
+ * Props for the results group link, the "to" prop defaults to "/search".
+ */
+ linkProps?: Partial;
+ /**
+ * A generic filter options that is rendered on the "Add filter" dropdown.
+ */
+ filterOptions?: FilterOption[];
+ /**
+ * 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;
+ /**
+ * A list of search filter keys, also known as filter field names.
+ */
+ filterFields?: string[];
+ /**
+ * Function to customize how filter chips are rendered.
+ */
+ renderFilterField?: (key: string) => JSX.Element | null;
+ /**
+ * Search results to be rendered as a group.
+ */
+ 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 group.
+ * @param props - See {@link SearchResultGroupLayoutProps}.
+ * @public
+ */
+export function SearchResultGroupLayout(
+ props: SearchResultGroupLayoutProps,
+) {
+ const classes = useStyles();
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const {
+ loading,
+ error,
+ icon,
+ title,
+ titleProps = {},
+ link,
+ linkProps = {},
+ filterOptions,
+ renderFilterOption,
+ filterFields,
+ renderFilterField,
+ resultItems,
+ renderResultItem,
+ ...rest
+ } = props;
+
+ const handleClick = useCallback((e: React.MouseEvent) => {
+ setAnchorEl(e.currentTarget);
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setAnchorEl(null);
+ }, []);
+
+ return (
+
+
+ {icon}
+
+ {title}
+
+ {filterOptions ? (
+ }
+ variant="outlined"
+ label="Add filter"
+ aria-controls="filters-menu"
+ aria-haspopup="true"
+ onClick={handleClick}
+ />
+ ) : null}
+ {filterOptions ? (
+
+ ) : null}
+ {filterFields?.map(
+ filterField => renderFilterField?.(filterField) ?? null,
+ )}
+
+ {link ?? (
+ <>
+ See all
+
+ >
+ )}
+
+
+ {loading ? : null}
+ {!loading && error ? (
+
+ ) : null}
+ {!loading && !error && resultItems?.length
+ ? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null)
+ : null}
+ {!loading && !error && !resultItems?.length ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 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;
+};
+
+/**
+ * Given a query, search for results and render them as a group.
+ * @param props - See {@link SearchResultGroupProps}.
+ * @public
+ */
+export function SearchResultGroup(
+ props: SearchResultGroupProps,
+) {
+ const {
+ query,
+ linkProps = {},
+ renderResultItem = ({ document }) => (
+
+ ),
+ ...rest
+ } = props;
+
+ const to = `/search?${qs.stringify(
+ {
+ query: query.term,
+ types: query.types,
+ filters: query.filters,
+ pageCursor: query.pageCursor,
+ },
+ { arrayFormat: 'brackets' },
+ )}`;
+
+ return (
+
+
+ {({ loading, error, value }) => (
+
+ )}
+
+
+ );
+}
diff --git a/plugins/search-react/src/components/SearchResultGroup/index.tsx b/plugins/search-react/src/components/SearchResultGroup/index.tsx
new file mode 100644
index 0000000000..5cab1320be
--- /dev/null
+++ b/plugins/search-react/src/components/SearchResultGroup/index.tsx
@@ -0,0 +1,32 @@
+/*
+ * 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 {
+ SearchResultGroup,
+ SearchResultGroupLayout,
+ SearchResultGroupTextFilterField,
+ SearchResultGroupSelectFilterField,
+ SearchResultGroupFilterFieldLayout,
+} from './SearchResultGroup';
+
+export type {
+ SearchResultGroupProps,
+ SearchResultGroupLayoutProps,
+ SearchResultGroupFilterFieldPropsWith,
+ SearchResultGroupFilterFieldLayoutProps,
+ SearchResultGroupTextFilterFieldProps,
+ SearchResultGroupSelectFilterFieldProps,
+} from './SearchResultGroup';
diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts
index 6cf7a22a7d..62d8b611a4 100644
--- a/plugins/search-react/src/components/index.ts
+++ b/plugins/search-react/src/components/index.ts
@@ -21,4 +21,5 @@ export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchResultList';
+export * from './SearchResultGroup';
export * from './DefaultResultListItem';