Merge pull request #13561 from backstage/camilal/search-group
[Search] Results group component
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user