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

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2022-09-13 11:25:18 +02:00
parent acb8804a77
commit 589e6a4fba
6 changed files with 512 additions and 0 deletions
@@ -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 = () => {
</SearchResult>
);
};
export const ListLayout = () => {
return (
<SearchResult>
{({ results }) => (
<SearchResultListLayout
resultItems={results}
renderResultItem={({ type, document }) => {
switch (type) {
case 'custom-result-item':
return (
<CustomResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
}}
/>
)}
</SearchResult>
);
};
@@ -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(
<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 <SearchResultList query={query} />;
};
export const Loading = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider
apis={[
[searchApiRef, { query: () => new Promise<SearchResultSet>(() => {}) }],
]}
>
<SearchResultList query={query} />
</TestApiProvider>
);
};
export const WithError = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider
apis={[
[
searchApiRef,
{
query: () =>
new Promise<SearchResultSet>(() => {
throw new Error();
}),
},
],
]}
>
<SearchResultList query={query} />
</TestApiProvider>
);
};
export const WithNoResults = () => {
const [query] = useState<Partial<SearchQuery>>({
types: ['techdocs'],
});
return (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchResultList query={query} />
</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 (
<SearchResultList
query={query}
renderResultItem={({ type, document, highlight, rank }) => {
switch (type) {
case 'custom':
return (
<CustomResultListItem
key={document.location}
icon={<CatalogIcon />}
result={document}
highlight={highlight}
rank={rank}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
}}
/>
);
};
@@ -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(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultList
query={{
types: ['techdocs'],
}}
/>
</TestApiProvider>,
),
);
expect(query).toHaveBeenCalledWith({
filters: {},
pageCursor: undefined,
term: '',
types: ['techdocs'],
});
});
it('Defines a default render result item', async () => {
query.mockResolvedValueOnce({
results,
});
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultList
query={{
types: ['techdocs'],
}}
/>
</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('Shows a progress bar when loading results', async () => {
query.mockReturnValueOnce(new Promise(() => {}));
await renderWithEffects(
wrapInTestApp(
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchResultList
query={{
types: ['techdocs'],
}}
/>
</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]]}>
<SearchResultList
query={{
types: ['techdocs'],
}}
/>
</TestApiProvider>,
),
);
await waitFor(() => {
expect(
screen.getByText(
'Error: Error encountered while fetching search results',
),
).toBeInTheDocument();
});
});
});
@@ -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 (
<List {...rest}>
{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 ? (
<EmptyState missing="data" title="Sorry, no results were found" />
) : null}
</List>
);
};
/**
* Props for {@link SearchResultList}.
* @public
*/
export type SearchResultListProps = Omit<
SearchResultListLayoutProps,
'loading' | 'error' | 'resultItems'
> & {
/**
* A search query used for requesting the results to be listed.
*/
query: Partial<SearchQuery>;
};
/**
* 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 }) => (
<DefaultResultListItem key={document.location} result={document} />
),
...rest
} = props;
return (
<AnalyticsContext
attributes={{
pluginId: 'search',
extension: 'SearchResultList',
}}
>
<SearchResultState query={query}>
{({ loading, error, value }) => (
<SearchResultListLayout
{...rest}
loading={loading}
error={error}
resultItems={value?.results}
renderResultItem={renderResultItem}
/>
)}
</SearchResultState>
</AnalyticsContext>
);
};
@@ -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';
@@ -20,4 +20,5 @@ export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchResultList';
export * from './DefaultResultListItem';