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';