feat(search-react): create search result list component
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user