Merge pull request #13561 from backstage/camilal/search-group

[Search] Results group component
This commit is contained in:
Camila Belo
2022-09-16 15:12:16 +02:00
committed by GitHub
18 changed files with 2425 additions and 70 deletions
+283
View File
@@ -0,0 +1,283 @@
---
'@backstage/plugin-search-react': minor
---
The `<SearchResult/>` component now accepts a optional `query` prop to request results from the search api:
> Note: If a query prop is not defined, the results will by default be consumed from the context.
Example:
```jsx
import React, { useState, useCallback } from 'react';
import { Grid, List, Paper } from '@material-ui/core';
import { Page, Header, Content, Lifecycle } from '@backstage/core-components';
import {
DefaultResultListItem,
SearchBarBase,
SearchResult,
} from '@backstage/plugin-search-react';
const SearchPage = () => {
const [query, setQuery] = useState({
term: '',
types: [],
filters: {},
});
const handleChange = useCallback(
(term: string) => {
setQuery(prevQuery => ({ ...prevQuery, term }));
},
[setQuery],
);
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<Paper>
<SearchBarBase debounceTime={100} onChange={handleChange} />
</Paper>
</Grid>
<Grid item xs>
<SearchResult query={query}>
{({ results }) => (
<List>
{results.map(({ document }) => (
<DefaultResultListItem
key={document.location}
result={document}
/>
))}
</List>
)}
</SearchResult>
</Grid>
</Grid>
</Content>
</Page>
);
};
```
Additionally, a search page can also be composed using these two new results layout components:
```jsx
// Example rendering results as list
<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>
```
```jsx
// Example rendering results as groups
<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>
```
A `SearchResultList` and `SearchResultGroup` components were also created for users who have search pages with multiple queries, both are specializations of `SearchResult` and also accept a `query` as a prop as well:
```jsx
// Example using the <SearchResultList />
const SearchPage = () => {
const query = {
term: 'example',
};
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}
/>
);
}
}}
/>
);
};
```
```jsx
// Example using the <SearchResultGroup /> for creating a component that search and group software catalog results
import React, { useState, useCallback } from 'react';
import { MenuItem } from '@material-ui/core';
import { JsonValue } from '@backstage/types';
import { CatalogIcon } from '@backstage/core-components';
import { CatalogSearchResultListItem } from '@backstage/plugin-catalog';
import {
SearchResultGroup,
SearchResultGroupTextFilterField,
SearchResultGroupSelectFilterField,
} from @backstage/plugin-search-react;
import { SearchQuery } from '@backstage/plugin-search-common';
const CatalogResultsGroup = () => {
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={<CatalogIcon />}
title="Software Catalog"
link="See all software catalog results"
filterOptions={filterOptions}
renderFilterOption={({ label, value }) => (
<MenuItem key={value} onClick={handleFilterAdd(value)}>
{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;
}
}
renderResultItem={({ document, highlight, rank }) => (
<CatalogSearchResultListItem
key={document.location}
result={document}
highlight={highlight}
rank={rank}
/>
)}
/>
);
};
```
+130 -6
View File
@@ -11,7 +11,10 @@ import { AutocompleteProps } from '@material-ui/lab';
import { ForwardRefExoticComponent } from 'react';
import { InputBaseProps } from '@material-ui/core';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { LinkProps } from '@backstage/core-components';
import { ListItemTextProps } from '@material-ui/core';
import { ListProps } from '@material-ui/core';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
@@ -21,6 +24,7 @@ import { SearchDocument } from '@backstage/plugin-search-common';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
import { TypographyProps } from '@material-ui/core';
// @public (undocumented)
export const AutocompleteFilter: (
@@ -205,22 +209,142 @@ export type SearchFilterWrapperProps = SearchFilterComponentProps & {
debug?: boolean;
};
// @public (undocumented)
// @public
export const SearchResult: (props: SearchResultProps) => JSX.Element;
// @public
export const SearchResultComponent: ({
children,
}: SearchResultProps) => JSX.Element;
export const SearchResultApi: (props: SearchResultApiProps) => JSX.Element;
// @public
export type SearchResultApiProps = SearchResultContextProps & {
query: Partial<SearchQuery>;
};
// @public
export const SearchResultComponent: (props: SearchResultProps) => JSX.Element;
// @public
export const SearchResultContext: (
props: SearchResultContextProps,
) => JSX.Element;
// @public
export type SearchResultContextProps = {
children: (state: AsyncState<SearchResultSet>) => JSX.Element;
};
// @public
export function SearchResultGroup<FilterOption>(
props: SearchResultGroupProps<FilterOption>,
): JSX.Element;
// @public
export const SearchResultGroupFilterFieldLayout: (
props: SearchResultGroupFilterFieldLayoutProps,
) => JSX.Element;
// @public
export type SearchResultGroupFilterFieldLayoutProps = PropsWithChildren<{
label: string;
value?: JsonValue;
onDelete: () => void;
}>;
// @public
export type SearchResultGroupFilterFieldPropsWith<T> = T &
SearchResultGroupFilterFieldLayoutProps & {
onChange: (value: JsonValue) => void;
};
// @public
export function SearchResultGroupLayout<FilterOption>(
props: SearchResultGroupLayoutProps<FilterOption>,
): JSX.Element;
// @public
export type SearchResultGroupLayoutProps<FilterOption> = ListProps & {
icon: JSX.Element;
title: ReactNode;
titleProps?: Partial<TypographyProps>;
link?: ReactNode;
linkProps?: Partial<LinkProps>;
filterOptions?: FilterOption[];
renderFilterOption?: (filterOption: FilterOption) => JSX.Element;
filterFields?: string[];
renderFilterField?: (key: string) => JSX.Element | null;
resultItems?: SearchResult_2[];
renderResultItem?: (resultItem: SearchResult_2) => JSX.Element;
error?: Error;
loading?: boolean;
};
// @public
export type SearchResultGroupProps<FilterOption> = Omit<
SearchResultGroupLayoutProps<FilterOption>,
'loading' | 'error' | 'resultItems' | 'filterFields'
> & {
query: Partial<SearchQuery>;
};
// @public
export const SearchResultGroupSelectFilterField: (
props: SearchResultGroupSelectFilterFieldProps,
) => JSX.Element;
// @public
export type SearchResultGroupSelectFilterFieldProps =
SearchResultGroupFilterFieldPropsWith<{
children: ReactNode;
}>;
// @public
export const SearchResultGroupTextFilterField: (
props: SearchResultGroupTextFilterFieldProps,
) => JSX.Element;
// @public
export type SearchResultGroupTextFilterFieldProps =
SearchResultGroupFilterFieldPropsWith<{}>;
// @public
export const SearchResultList: (props: SearchResultListProps) => JSX.Element;
// @public
export const SearchResultListLayout: (
props: SearchResultListLayoutProps,
) => JSX.Element;
// @public
export type SearchResultListLayoutProps = ListProps & {
resultItems?: SearchResult_2[];
renderResultItem?: (resultItem: SearchResult_2) => JSX.Element;
error?: Error;
loading?: boolean;
};
// @public
export type SearchResultListProps = Omit<
SearchResultListLayoutProps,
'loading' | 'error' | 'resultItems'
> & {
query: Partial<SearchQuery>;
};
// @public (undocumented)
export const SearchResultPager: () => JSX.Element;
// @public
export type SearchResultProps = {
children: (results: { results: SearchResult_2[] }) => JSX.Element;
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> & {
children: (resultSet: SearchResultSet) => JSX.Element;
};
// @public
export const SearchResultState: (props: SearchResultStateProps) => JSX.Element;
// @public
export type SearchResultStateProps = SearchResultContextProps &
Partial<SearchResultApiProps>;
// @public (undocumented)
export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element;
+1
View File
@@ -40,6 +40,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"qs": "^6.9.4",
"react-use": "^17.3.2"
},
"peerDependencies": {
@@ -15,16 +15,24 @@
*/
import React, { ComponentType } from 'react';
import { List, ListItem } from '@material-ui/core';
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';
import { SearchDocument } from '@backstage/plugin-search-common';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchResultListLayout } from '../SearchResultList';
import { SearchResult } from './SearchResult';
import { SearchResultGroupLayout } from '../SearchResultGroup';
const mockResults = {
results: [
@@ -55,15 +63,15 @@ const mockResults = {
],
};
const searchApiMock = new MockSearchApi(mockResults);
export default {
title: 'Plugins/Search/SearchResult',
component: SearchResult,
decorators: [
(Story: ComponentType<{}>) => (
<MemoryRouter>
<TestApiProvider
apis={[[searchApiRef, new MockSearchApi(mockResults)]]}
>
<TestApiProvider apis={[[searchApiRef, searchApiMock]]}>
<SearchContextProvider>
<Story />
</SearchContextProvider>
@@ -73,6 +81,17 @@ export default {
],
};
const CustomResultListItem = (props: { result: SearchDocument }) => {
const { result } = props;
return (
<ListItem>
<Link to={result.location}>
{result.title} - {result.text}
</Link>
</ListItem>
);
};
export const Default = () => {
return (
<SearchResult>
@@ -82,18 +101,17 @@ export const Default = () => {
switch (type) {
case 'custom-result-item':
return (
<DefaultResultListItem
<CustomResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<ListItem>
<Link to={document.location}>
{document.title} - {document.text}
</Link>
</ListItem>
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
@@ -102,3 +120,101 @@ export const Default = () => {
</SearchResult>
);
};
export const WithQuery = () => {
const query = {
term: 'documentation',
};
return (
<SearchResult query={query}>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'custom-result-item':
return (
<CustomResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
}
})}
</List>
)}
</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>
);
};
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>
);
};
@@ -15,69 +15,215 @@
*/
import React from 'react';
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
import {
EmptyState,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
import { AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchResult } from '@backstage/plugin-search-common';
import { AnalyticsContext, useApi } from '@backstage/core-plugin-api';
import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common';
import { useSearch } from '../../context';
import { searchApiRef } from '../../api';
/**
* Props for {@link SearchResultComponent}
*
* Props for {@link SearchResultContext}
* @public
*/
export type SearchResultProps = {
children: (results: { results: SearchResult[] }) => JSX.Element;
export type SearchResultContextProps = {
/**
* A child function that receives an asynchronous result set and returns a react element.
*/
children: (state: AsyncState<SearchResultSet>) => JSX.Element;
};
/**
* A component returning the search result.
*
* Provides context-based results to a child function.
* @param props - see {@link SearchResultContextProps}.
* @example
* ```
* <SearchResultContext>
* {({ loading, error, value }) => (
* <List>
* {value?.map(({ document }) => (
* <DefaultSearchResultListItem
* key={document.location}
* result={document}
* />
* ))}
* </List>
* )}
* </SearchResultContext>
* ```
* @public
*/
export const SearchResultComponent = ({ children }: SearchResultProps) => {
const {
result: { loading, error, value },
} = useSearch();
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!value?.results.length) {
return <EmptyState missing="data" title="Sorry, no results were found" />;
}
return <>{children({ results: value.results })}</>;
export const SearchResultContext = (props: SearchResultContextProps) => {
const { children } = props;
const context = useSearch();
const state = context.result;
return children(state);
};
/**
* Props for {@link SearchResultApi}
* @public
*/
const HigherOrderSearchResult = (props: SearchResultProps) => {
return (
<AnalyticsContext
attributes={{
pluginId: 'search',
extension: 'SearchResult',
}}
>
<SearchResultComponent {...props} />
</AnalyticsContext>
export type SearchResultApiProps = SearchResultContextProps & {
query: Partial<SearchQuery>;
};
/**
* Request results through the search api and provide them to a child function.
* @param props - see {@link SearchResultApiProps}.
* @example
* ```
* <SearchResultApi>
* {({ loading, error, value }) => (
* <List>
* {value?.map(({ document }) => (
* <DefaultSearchResultListItem
* key={document.location}
* result={document}
* />
* ))}
* </List>
* )}
* </SearchResultApi>
* ```
* @public
*/
export const SearchResultApi = (props: SearchResultApiProps) => {
const { query, children } = props;
const searchApi = useApi(searchApiRef);
const state = useAsync(
() =>
searchApi.query({
term: query.term ?? '',
types: query.types ?? [],
filters: query.filters ?? {},
pageCursor: query.pageCursor,
}),
[query],
);
return children(state);
};
/**
* Props for {@link SearchResultState}
* @public
*/
export type SearchResultStateProps = SearchResultContextProps &
Partial<SearchResultApiProps>;
/**
* Call a child render function passing a search state as an argument.
* @remarks By default, results are taken from context, but when a "query" prop is set, results are requested from the search api.
* @param props - see {@link SearchResultStateProps}.
* @example
* Consuming results from context:
* ```
* <SearchResultState>
* {({ loading, error, value }) => (
* <List>
* {value?.map(({ document }) => (
* <DefaultSearchResultListItem
* key={document.location}
* result={document}
* />
* ))}
* </List>
* )}
* </SearchResultState>
* ```
* @example
* Requesting results using the search api:
* ```
* <SearchResultState query={{ term: 'documentation' }}>
* {({ loading, error, value }) => (
* <List>
* {value?.map(({ document }) => (
* <DefaultSearchResultListItem
* key={document.location}
* result={document}
* />
* ))}
* </List>
* )}
* </SearchResultState>
* ```
* @public
*/
export const SearchResultState = (props: SearchResultStateProps) => {
const { query, children } = props;
return query ? (
<SearchResultApi query={query}>{children}</SearchResultApi>
) : (
<SearchResultContext>{children}</SearchResultContext>
);
};
export { HigherOrderSearchResult as SearchResult };
/**
* Props for {@link SearchResult}
* @public
*/
export type SearchResultProps = Pick<SearchResultStateProps, 'query'> & {
children: (resultSet: SearchResultSet) => JSX.Element;
};
/**
* Renders results from a parent search context or api.
* @remarks default components for loading, error and empty variants are returned.
* @param props - see {@link SearchResultProps}.
* @public
*/
export const SearchResultComponent = (props: SearchResultProps) => {
const { query, children } = props;
return (
<SearchResultState query={query}>
{({ loading, error, value }) => {
if (loading) {
return <Progress />;
}
if (error) {
return (
<ResponseErrorPanel
title="Error encountered while fetching search results"
error={error}
/>
);
}
if (!value?.results.length) {
return (
<EmptyState missing="data" title="Sorry, no results were found" />
);
}
return children(value);
}}
</SearchResultState>
);
};
/**
* A component returning the search result from a parent search context or api.
* @param props - see {@link SearchResultProps}.
* @public
*/
export const SearchResult = (props: SearchResultProps) => (
<AnalyticsContext
attributes={{
pluginId: 'search',
extension: 'SearchResult',
}}
>
<SearchResultComponent {...props} />
</AnalyticsContext>
);
@@ -14,5 +14,17 @@
* limitations under the License.
*/
export { SearchResult, SearchResultComponent } from './SearchResult';
export type { SearchResultProps } from './SearchResult';
export {
SearchResult,
SearchResultApi,
SearchResultContext,
SearchResultState,
SearchResultComponent,
} from './SearchResult';
export type {
SearchResultProps,
SearchResultApiProps,
SearchResultContextProps,
SearchResultStateProps,
} from './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';
@@ -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,6 @@ export * from './SearchAutocomplete';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchResultList';
export * from './SearchResultGroup';
export * from './DefaultResultListItem';
@@ -14,13 +14,6 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/types';
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchResultSet } from '@backstage/plugin-search-common';
import {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import React, {
PropsWithChildren,
useCallback,
@@ -30,6 +23,15 @@ import React, {
} from 'react';
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
import usePrevious from 'react-use/lib/usePrevious';
import {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import { JsonObject } from '@backstage/types';
import { AnalyticsContext, useApi } from '@backstage/core-plugin-api';
import { SearchResultSet } from '@backstage/plugin-search-common';
import { searchApiRef } from '../api';
/**
@@ -104,12 +106,13 @@ const useSearchContextValue = (
initialValue: SearchContextState = searchInitialState,
) => {
const searchApi = useApi(searchApiRef);
const [term, setTerm] = useState<string>(initialValue.term);
const [types, setTypes] = useState<string[]>(initialValue.types);
const [filters, setFilters] = useState<JsonObject>(initialValue.filters);
const [pageCursor, setPageCursor] = useState<string | undefined>(
initialValue.pageCursor,
);
const [filters, setFilters] = useState<JsonObject>(initialValue.filters);
const [term, setTerm] = useState<string>(initialValue.term);
const [types, setTypes] = useState<string[]>(initialValue.types);
const prevTerm = usePrevious(term);
@@ -121,7 +124,7 @@ const useSearchContextValue = (
pageCursor,
types,
}),
[term, filters, types, pageCursor],
[term, types, filters, pageCursor],
);
const hasNextPage =
@@ -19,6 +19,7 @@ export {
useSearch,
useSearchContextCheck,
} from './SearchContext';
export type {
SearchContextProviderProps,
SearchContextState,
+1
View File
@@ -6633,6 +6633,7 @@ __metadata:
"@testing-library/react": ^12.1.3
"@testing-library/react-hooks": ^8.0.0
"@testing-library/user-event": ^14.0.0
qs: ^6.9.4
react-use: ^17.3.2
peerDependencies:
"@types/react": ^16.13.1 || ^17.0.0