feat(search-react): create search result group component

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-09-13 11:25:50 +02:00
parent 589e6a4fba
commit 69ce53d588
6 changed files with 1225 additions and 0 deletions
@@ -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 = () => {
</SearchResult>
);
};
export const GroupLayout = () => {
return (
<SearchResult>
{({ results }) => (
<>
<SearchResultGroupLayout
icon={<CustomIcon />}
title="Custom"
link="See all custom results"
resultItems={results.filter(
({ type }) => type === 'custom-result-item',
)}
renderResultItem={({ document }) => (
<CustomResultListItem key={document.location} result={document} />
)}
/>
<SearchResultGroupLayout
icon={<DefaultIcon />}
title="Default"
resultItems={results.filter(
({ type }) => type !== 'custom-result-item',
)}
renderResultItem={({ document }) => (
<DefaultResultListItem
key={document.location}
result={document}
/>
)}
/>
</>
)}
</SearchResult>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</TestApiProvider>,
{ mountedRoutes: { '/': routeRef } },
),
],
};
export const Default = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
/>
);
};
export const Loading = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider
apis={[
[searchApiRef, { query: () => new Promise<SearchResultSet>(() => {}) }],
]}
>
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
/>
</TestApiProvider>
);
};
export const WithError = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider
apis={[
[
searchApiRef,
{
query: () =>
new Promise<SearchResultSet>(() => {
throw new Error();
}),
},
],
]}
>
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
/>
</TestApiProvider>
);
};
export const WithCustomTitle = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['custom'],
});
return (
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Custom"
titleProps={{ color: 'secondary' }}
/>
);
};
export const WithCustomLink = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['custom'],
});
return (
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Custom"
link="See all custom results"
linkProps={{ to: '/custom' }}
/>
);
};
export const WithFilters = () => {
const [query, setQuery] = useState<Partial<SearchQuery>>({
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 (
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
filterOptions={filterOptions}
renderFilterOption={option => (
<MenuItem key={option.value} onClick={handleFilterAdd(option.value)}>
{option.label}
</MenuItem>
)}
renderFilterField={(key: string) => {
switch (key) {
case 'lifecycle':
return (
<SearchResultGroupSelectFilterField
key={key}
label="Lifecycle"
value={query.filters?.lifecycle}
onChange={handleFilterChange('lifecycle')}
onDelete={handleFilterDelete('lifecycle')}
>
<MenuItem value="production">Production</MenuItem>
<MenuItem value="experimental">Experimental</MenuItem>
</SearchResultGroupSelectFilterField>
);
case 'owner':
return (
<SearchResultGroupTextFilterField
key={key}
label="Owner"
value={query.filters?.owner}
onChange={handleFilterChange('owner')}
onDelete={handleFilterDelete('owner')}
/>
);
default:
return null;
}
}}
/>
);
};
export const WithNoResults = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Documentation"
/>
</TestApiProvider>
);
};
const CustomResultListItem = (props: any) => {
const { icon, result } = props;
return (
<Link to={result.location}>
<ListItem alignItems="flex-start" divider>
{icon && <ListItemIcon>{icon}</ListItemIcon>}
<ListItemText
primary={result.title}
primaryTypographyProps={{ variant: 'h6' }}
secondary={result.text}
/>
</ListItem>
</Link>
);
};
export const WithCustomResultItem = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['custom'],
});
return (
<SearchResultGroup
query={query}
icon={<DocsIcon />}
title="Custom"
link="See all custom results"
renderResultItem={({ document, highlight, rank }) => (
<CustomResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
)}
/>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</TestApiProvider>,
),
);
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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</TestApiProvider>,
),
);
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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</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('Could be customized with no results text', async () => {
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</TestApiProvider>,
),
);
expect(
screen.getByText('Sorry, no results were found'),
).toBeInTheDocument();
});
it('Could be customized with filters', async () => {
query.mockResolvedValueOnce({
results,
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
filterOptions={['lifecycle', 'owner']}
/>
</TestApiProvider>,
),
);
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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{
types: ['techdocs'],
filters: { owner: null },
}}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
filterOptions={['owner']}
renderFilterField={(key: string) =>
key === 'owner' ? (
<SearchResultGroupTextFilterField
key={key}
label="Owner"
onChange={handleFilterChange}
onDelete={handleFilterDelete}
/>
) : null
}
/>
</TestApiProvider>,
),
);
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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{
types: ['techdocs'],
filters: { lifecycle: null },
}}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
filterOptions={['lifecycle']}
renderFilterField={(key: string) =>
key === 'lifecycle' ? (
<SearchResultGroupSelectFilterField
key={key}
label="Lifecycle"
onChange={handleFilterChange}
onDelete={handleFilterDelete}
>
<MenuItem value="production">Production</MenuItem>
<MenuItem value="experimental">Experimental</MenuItem>
</SearchResultGroupSelectFilterField>
) : null
}
/>
</TestApiProvider>,
),
);
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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});
it('Shows an error panel when results rendering fails', async () => {
query.mockRejectedValueOnce(new Error());
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultGroup
query={{ types: ['techdocs'] }}
icon={<DocsIcon titleAccess="Docs icon" />}
title="Documentation"
/>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(
screen.getByText(
'Error: Error encountered while fetching search results',
),
).toBeInTheDocument();
});
});
});
@@ -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 (
<Chip
{...rest}
className={classes.listSubheaderFilter}
variant="outlined"
label={
<>
{label}: {children}
</>
}
/>
);
};
const NullIcon = () => null;
/**
* Common props for a result group filter field.
* @public
*/
export type SearchResultGroupFilterFieldPropsWith<T> = 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
* ```
* <SearchResultGroupTextFilterField
* id="lifecycle"
* label="Lifecycle"
* value={value}
* onChange={handleChangeFilter}
* onDelete={handleDeleteFilter}
* />
* ```
* @public
*/
export const SearchResultGroupTextFilterField = (
props: SearchResultGroupTextFilterFieldProps,
) => {
const classes = useSearchResultGroupTextFilterStyles();
const { label, value = 'None', onChange, onDelete } = props;
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
[onChange],
);
return (
<SearchResultGroupFilterFieldLayout label={label} onDelete={onDelete}>
<Typography
role="textbox"
component="span"
className={classes.root}
onChange={handleChange}
contentEditable
suppressContentEditableWarning
>
{value}
</Typography>
</SearchResultGroupFilterFieldLayout>
);
};
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
* ```
* <SearchResultGroupSelectFilterField
* id="lifecycle"
* label="Lifecycle"
* value={filters.lifecycle}
* onChange={handleChangeFilter}
* onDelete={handleDeleteFilter}
* >
* <MenuItem value="experimental">Experimental</MenuItem>
* <MenuItem value="production">Production</MenuItem>
* </SearchResultGroupSelectFilterField>
* ```
* @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 (
<SearchResultGroupFilterFieldLayout label={label} onDelete={onDelete}>
<Select
className={classes.root}
value={value}
onChange={handleChange}
input={<InputBase />}
IconComponent={NullIcon}
>
<MenuItem value="none">None</MenuItem>
{children}
</Select>
</SearchResultGroupFilterFieldLayout>
);
};
/**
* Props for {@link SearchResultGroupLayout}
* @public
*/
export type SearchResultGroupLayoutProps<FilterOption> = 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<TypographyProps>;
/**
* 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<LinkProps>;
/**
* 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<FilterOption>(
props: SearchResultGroupLayoutProps<FilterOption>,
) {
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const {
loading,
error,
icon,
title,
titleProps = {},
link,
linkProps = {},
filterOptions,
renderFilterOption,
filterFields,
renderFilterField,
resultItems,
renderResultItem,
...rest
} = props;
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(e.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
return (
<List {...rest}>
<ListSubheader className={classes.listSubheader}>
{icon}
<Typography
className={classes.listSubheaderName}
component="strong"
{...titleProps}
>
{title}
</Typography>
{filterOptions ? (
<Chip
className={classes.listSubheaderChip}
component="button"
icon={<AddIcon />}
variant="outlined"
label="Add filter"
aria-controls="filters-menu"
aria-haspopup="true"
onClick={handleClick}
/>
) : null}
{filterOptions ? (
<Menu
id="filters-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
onClick={handleClose}
keepMounted
>
{filterOptions.map(filterOption =>
renderFilterOption ? (
renderFilterOption(filterOption)
) : (
<MenuItem
key={String(filterOption)}
value={String(filterOption)}
>
{filterOption}
</MenuItem>
),
)}
</Menu>
) : null}
{filterFields?.map(
filterField => renderFilterField?.(filterField) ?? null,
)}
<Link className={classes.listSubheaderLink} to="/search" {...linkProps}>
{link ?? (
<>
See all
<ArrowRightIcon className={classes.listSubheaderLinkIcon} />
</>
)}
</Link>
</ListSubheader>
{loading ? <Progress /> : null}
{!loading && error ? (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
) : null}
{!loading && !error && resultItems?.length
? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null)
: null}
{!loading && !error && !resultItems?.length ? (
<ListItem>
<EmptyState missing="data" title="Sorry, no results were found" />
</ListItem>
) : null}
</List>
);
}
/**
* 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>;
};
/**
* Given a query, search for results and render them as a group.
* @param props - See {@link SearchResultGroupProps}.
* @public
*/
export function SearchResultGroup<FilterOption>(
props: SearchResultGroupProps<FilterOption>,
) {
const {
query,
linkProps = {},
renderResultItem = ({ document }) => (
<DefaultResultListItem key={document.location} result={document} />
),
...rest
} = props;
const to = `/search?${qs.stringify(
{
query: query.term,
types: query.types,
filters: query.filters,
pageCursor: query.pageCursor,
},
{ arrayFormat: 'brackets' },
)}`;
return (
<AnalyticsContext
attributes={{
pluginId: 'search',
extension: 'SearchResultGroup',
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => (
<SearchResultGroupLayout
{...rest}
loading={loading}
error={error}
linkProps={{ to, ...linkProps }}
resultItems={value?.results}
renderResultItem={renderResultItem}
filterFields={Object.keys(query.filters ?? {})}
/>
)}
</SearchResultState>
</AnalyticsContext>
);
}
@@ -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';
@@ -21,4 +21,5 @@ export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchResultList';
export * from './SearchResultGroup';
export * from './DefaultResultListItem';