diff --git a/.changeset/search-countries-exercise.md b/.changeset/search-countries-exercise.md new file mode 100644 index 0000000000..390813444f --- /dev/null +++ b/.changeset/search-countries-exercise.md @@ -0,0 +1,283 @@ +--- +'@backstage/plugin-search-react': minor +--- + +The `` 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 ( + +
} /> + + + + + + + + + + {({ results }) => ( + + {results.map(({ document }) => ( + + ))} + + )} + + + + + + ); +}; +``` + +Additionally, a search page can also be composed using these two new results layout components: + +```jsx +// Example rendering results as list + + {({ results }) => ( + { + switch (type) { + case 'custom-result-item': + return ( + + ); + default: + return ( + + ); + } + }} + /> + )} + +``` + +```jsx +// Example rendering results as groups + + {({ results }) => ( + <> + } + title="Custom" + link="See all custom results" + resultItems={results.filter( + ({ type }) => type === 'custom-result-item', + )} + renderResultItem={({ document }) => ( + + )} + /> + } + title="Default" + resultItems={results.filter( + ({ type }) => type !== 'custom-result-item', + )} + renderResultItem={({ document }) => ( + + )} + /> + + )} + +``` + +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 +const SearchPage = () => { + const query = { + term: 'example', + }; + + return ( + { + switch (type) { + case 'custom': + return ( + } + result={document} + highlight={highlight} + rank={rank} + /> + ); + default: + return ( + + ); + } + }} + /> + ); +}; +``` + +```jsx +// Example using the 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>({ + 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 ( + } + title="Software Catalog" + link="See all software catalog results" + filterOptions={filterOptions} + renderFilterOption={({ label, value }) => ( + + {label} + + )} + renderFilterField={(key: string) => { + switch (key) { + case 'lifecycle': + return ( + + Production + Experimental + + ); + case 'owner': + return ( + + ); + default: + return null; + } + } + renderResultItem={({ document, highlight, rank }) => ( + + )} + /> + ); +}; +``` diff --git a/plugins/search-react/api-report.md b/plugins/search-react/api-report.md index 7d5e64757b..5ec4124fcc 100644 --- a/plugins/search-react/api-report.md +++ b/plugins/search-react/api-report.md @@ -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; +}; + +// @public +export const SearchResultComponent: (props: SearchResultProps) => JSX.Element; + +// @public +export const SearchResultContext: ( + props: SearchResultContextProps, +) => JSX.Element; + +// @public +export type SearchResultContextProps = { + children: (state: AsyncState) => JSX.Element; +}; + +// @public +export function SearchResultGroup( + props: SearchResultGroupProps, +): 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 & + SearchResultGroupFilterFieldLayoutProps & { + onChange: (value: JsonValue) => void; + }; + +// @public +export function SearchResultGroupLayout( + props: SearchResultGroupLayoutProps, +): JSX.Element; + +// @public +export type SearchResultGroupLayoutProps = ListProps & { + icon: JSX.Element; + title: ReactNode; + titleProps?: Partial; + link?: ReactNode; + linkProps?: Partial; + 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 = Omit< + SearchResultGroupLayoutProps, + 'loading' | 'error' | 'resultItems' | 'filterFields' +> & { + query: Partial; +}; + +// @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; +}; // @public (undocumented) export const SearchResultPager: () => JSX.Element; // @public -export type SearchResultProps = { - children: (results: { results: SearchResult_2[] }) => JSX.Element; +export type SearchResultProps = Pick & { + children: (resultSet: SearchResultSet) => JSX.Element; }; +// @public +export const SearchResultState: (props: SearchResultStateProps) => JSX.Element; + +// @public +export type SearchResultStateProps = SearchResultContextProps & + Partial; + // @public (undocumented) export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element; diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 1afdc3c406..826d751353 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -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": { diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx index 32f9a19a07..4b8d9bbdd4 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx @@ -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<{}>) => ( - + @@ -73,6 +81,17 @@ export default { ], }; +const CustomResultListItem = (props: { result: SearchDocument }) => { + const { result } = props; + return ( + + + {result.title} - {result.text} + + + ); +}; + export const Default = () => { return ( @@ -82,18 +101,17 @@ export const Default = () => { switch (type) { case 'custom-result-item': return ( - ); default: return ( - - - {document.title} - {document.text} - - + ); } })} @@ -102,3 +120,101 @@ export const Default = () => { ); }; + +export const WithQuery = () => { + const query = { + term: 'documentation', + }; + + return ( + + {({ results }) => ( + + {results.map(({ type, document }) => { + switch (type) { + case 'custom-result-item': + return ( + + ); + default: + return ( + + ); + } + })} + + )} + + ); +}; + +export const ListLayout = () => { + return ( + + {({ results }) => ( + { + switch (type) { + case 'custom-result-item': + return ( + + ); + default: + return ( + + ); + } + }} + /> + )} + + ); +}; + +export const GroupLayout = () => { + return ( + + {({ results }) => ( + <> + } + title="Custom" + link="See all custom results" + resultItems={results.filter( + ({ type }) => type === 'custom-result-item', + )} + renderResultItem={({ document }) => ( + + )} + /> + } + title="Default" + resultItems={results.filter( + ({ type }) => type !== 'custom-result-item', + )} + renderResultItem={({ document }) => ( + + )} + /> + + )} + + ); +}; diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.tsx index 46a2dde305..a3e4c0e1f0 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.tsx @@ -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) => JSX.Element; }; /** - * A component returning the search result. - * + * Provides context-based results to a child function. + * @param props - see {@link SearchResultContextProps}. + * @example + * ``` + * + * {({ loading, error, value }) => ( + * + * {value?.map(({ document }) => ( + * + * ))} + * + * )} + * + * ``` * @public */ -export const SearchResultComponent = ({ children }: SearchResultProps) => { - const { - result: { loading, error, value }, - } = useSearch(); - - if (loading) { - return ; - } - if (error) { - return ( - - ); - } - - if (!value?.results.length) { - return ; - } - - 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 ( - - - +export type SearchResultApiProps = SearchResultContextProps & { + query: Partial; +}; + +/** + * Request results through the search api and provide them to a child function. + * @param props - see {@link SearchResultApiProps}. + * @example + * ``` + * + * {({ loading, error, value }) => ( + * + * {value?.map(({ document }) => ( + * + * ))} + * + * )} + * + * ``` + * @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; + +/** + * 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: + * ``` + * + * {({ loading, error, value }) => ( + * + * {value?.map(({ document }) => ( + * + * ))} + * + * )} + * + * ``` + * @example + * Requesting results using the search api: + * ``` + * + * {({ loading, error, value }) => ( + * + * {value?.map(({ document }) => ( + * + * ))} + * + * )} + * + * ``` + * @public + */ +export const SearchResultState = (props: SearchResultStateProps) => { + const { query, children } = props; + + return query ? ( + {children} + ) : ( + {children} ); }; -export { HigherOrderSearchResult as SearchResult }; +/** + * Props for {@link SearchResult} + * @public + */ +export type SearchResultProps = Pick & { + 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 ( + + {({ loading, error, value }) => { + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + if (!value?.results.length) { + return ( + + ); + } + + return children(value); + }} + + ); +}; + +/** + * A component returning the search result from a parent search context or api. + * @param props - see {@link SearchResultProps}. + * @public + */ +export const SearchResult = (props: SearchResultProps) => ( + + + +); diff --git a/plugins/search-react/src/components/SearchResult/index.tsx b/plugins/search-react/src/components/SearchResult/index.tsx index 503ac47bbd..eab599883b 100644 --- a/plugins/search-react/src/components/SearchResult/index.tsx +++ b/plugins/search-react/src/components/SearchResult/index.tsx @@ -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'; diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx new file mode 100644 index 0000000000..3a20dec36d --- /dev/null +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.stories.tsx @@ -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( + + + + + + + , + { mountedRoutes: { '/': routeRef } }, + ), + ], +}; + +export const Default = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + } + title="Documentation" + /> + ); +}; + +export const Loading = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + new Promise(() => {}) }], + ]} + > + } + title="Documentation" + /> + + ); +}; + +export const WithError = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + + new Promise(() => { + throw new Error(); + }), + }, + ], + ]} + > + } + title="Documentation" + /> + + ); +}; + +export const WithCustomTitle = () => { + const [query] = useState>({ + types: ['custom'], + }); + + return ( + } + title="Custom" + titleProps={{ color: 'secondary' }} + /> + ); +}; + +export const WithCustomLink = () => { + const [query] = useState>({ + types: ['custom'], + }); + + return ( + } + title="Custom" + link="See all custom results" + linkProps={{ to: '/custom' }} + /> + ); +}; + +export const WithFilters = () => { + const [query, setQuery] = useState>({ + 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 ( + } + title="Documentation" + filterOptions={filterOptions} + renderFilterOption={option => ( + + {option.label} + + )} + renderFilterField={(key: string) => { + switch (key) { + case 'lifecycle': + return ( + + Production + Experimental + + ); + case 'owner': + return ( + + ); + default: + return null; + } + }} + /> + ); +}; + +export const WithNoResults = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + + } + title="Documentation" + /> + + ); +}; + +const CustomResultListItem = (props: any) => { + const { icon, result } = props; + + return ( + + + {icon && {icon}} + + + + ); +}; + +export const WithCustomResultItem = () => { + const [query] = useState>({ + types: ['custom'], + }); + + return ( + } + title="Custom" + link="See all custom results" + renderResultItem={({ document, highlight, rank }) => ( + + )} + /> + ); +}; diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx new file mode 100644 index 0000000000..c1b45cc433 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.test.tsx @@ -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( + + } + title="Documentation" + /> + , + ), + ); + + 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( + + } + title="Documentation" + /> + , + ), + ); + + 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( + + } + title="Documentation" + /> + , + ), + ); + + 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( + + } + title="Documentation" + /> + , + ), + ); + + expect( + screen.getByText('Sorry, no results were found'), + ).toBeInTheDocument(); + }); + + it('Could be customized with filters', async () => { + query.mockResolvedValueOnce({ + results, + }); + + await renderWithEffects( + wrapInTestApp( + + } + title="Documentation" + filterOptions={['lifecycle', 'owner']} + /> + , + ), + ); + + 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( + + } + title="Documentation" + filterOptions={['owner']} + renderFilterField={(key: string) => + key === 'owner' ? ( + + ) : null + } + /> + , + ), + ); + + 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( + + } + title="Documentation" + filterOptions={['lifecycle']} + renderFilterField={(key: string) => + key === 'lifecycle' ? ( + + Production + Experimental + + ) : null + } + /> + , + ), + ); + + 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( + + } + title="Documentation" + /> + , + ), + ); + + await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); + + it('Shows an error panel when results rendering fails', async () => { + query.mockRejectedValueOnce(new Error()); + await renderWithEffects( + wrapInTestApp( + + } + title="Documentation" + /> + , + ), + ); + + await waitFor(() => { + expect( + screen.getByText( + 'Error: Error encountered while fetching search results', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx new file mode 100644 index 0000000000..9c55996ca7 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultGroup/SearchResultGroup.tsx @@ -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 ( + + {label}: {children} + + } + /> + ); +}; + +const NullIcon = () => null; + +/** + * Common props for a result group filter field. + * @public + */ +export type SearchResultGroupFilterFieldPropsWith = 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 + * ``` + * + * ``` + * @public + */ +export const SearchResultGroupTextFilterField = ( + props: SearchResultGroupTextFilterFieldProps, +) => { + const classes = useSearchResultGroupTextFilterStyles(); + const { label, value = 'None', onChange, onDelete } = props; + + const handleChange = useCallback( + (e: ChangeEvent) => { + onChange(e.target.value); + }, + [onChange], + ); + + return ( + + + {value} + + + ); +}; + +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 + * ``` + * + * Experimental + * Production + * + * ``` + * @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 ( + + + + ); +}; + +/** + * Props for {@link SearchResultGroupLayout} + * @public + */ +export type SearchResultGroupLayoutProps = 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; + /** + * 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; + /** + * 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( + props: SearchResultGroupLayoutProps, +) { + const classes = useStyles(); + const [anchorEl, setAnchorEl] = useState(null); + + const { + loading, + error, + icon, + title, + titleProps = {}, + link, + linkProps = {}, + filterOptions, + renderFilterOption, + filterFields, + renderFilterField, + resultItems, + renderResultItem, + ...rest + } = props; + + const handleClick = useCallback((e: React.MouseEvent) => { + setAnchorEl(e.currentTarget); + }, []); + + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + return ( + + + {icon} + + {title} + + {filterOptions ? ( + } + variant="outlined" + label="Add filter" + aria-controls="filters-menu" + aria-haspopup="true" + onClick={handleClick} + /> + ) : null} + {filterOptions ? ( + + {filterOptions.map(filterOption => + renderFilterOption ? ( + renderFilterOption(filterOption) + ) : ( + + {filterOption} + + ), + )} + + ) : null} + {filterFields?.map( + filterField => renderFilterField?.(filterField) ?? null, + )} + + {link ?? ( + <> + See all + + + )} + + + {loading ? : null} + {!loading && error ? ( + + ) : null} + {!loading && !error && resultItems?.length + ? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null) + : null} + {!loading && !error && !resultItems?.length ? ( + + + + ) : null} + + ); +} + +/** + * Props for {@link SearchResultGroup}. + * @public + */ +export type SearchResultGroupProps = Omit< + SearchResultGroupLayoutProps, + 'loading' | 'error' | 'resultItems' | 'filterFields' +> & { + /** + * A search query used for requesting the results to be grouped. + */ + query: Partial; +}; + +/** + * Given a query, search for results and render them as a group. + * @param props - See {@link SearchResultGroupProps}. + * @public + */ +export function SearchResultGroup( + props: SearchResultGroupProps, +) { + const { + query, + linkProps = {}, + renderResultItem = ({ document }) => ( + + ), + ...rest + } = props; + + const to = `/search?${qs.stringify( + { + query: query.term, + types: query.types, + filters: query.filters, + pageCursor: query.pageCursor, + }, + { arrayFormat: 'brackets' }, + )}`; + + return ( + + + {({ loading, error, value }) => ( + + )} + + + ); +} diff --git a/plugins/search-react/src/components/SearchResultGroup/index.tsx b/plugins/search-react/src/components/SearchResultGroup/index.tsx new file mode 100644 index 0000000000..5cab1320be --- /dev/null +++ b/plugins/search-react/src/components/SearchResultGroup/index.tsx @@ -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'; diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx new file mode 100644 index 0000000000..5762aba2d8 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.stories.tsx @@ -0,0 +1,182 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentType, useState } from 'react'; + +import { Grid, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'; + +import { createRouteRef } from '@backstage/core-plugin-api'; +import { CatalogIcon, Link } from '@backstage/core-components'; +import { SearchQuery, SearchResultSet } from '@backstage/plugin-search-common'; +import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; + +import { searchApiRef, MockSearchApi } from '../../api'; + +import { SearchResultList } from './SearchResultList'; +import { DefaultResultListItem } from '../DefaultResultListItem'; + +const routeRef = createRouteRef({ + id: 'storybook.search.results.list.route', +}); + +const searchApiMock = new MockSearchApi({ + results: [ + { + type: 'techdocs', + document: { + location: 'search/search-result1', + title: 'Search Result 1', + text: 'Some text from the search result 1', + }, + }, + { + type: 'custom', + document: { + location: 'search/search-result2', + title: 'Search Result 2', + text: 'Some text from the search result 2', + }, + }, + ], +}); + +export default { + title: 'Plugins/Search/SearchResultList', + component: SearchResultList, + decorators: [ + (Story: ComponentType<{}>) => + wrapInTestApp( + + + + + + + , + { mountedRoutes: { '/': routeRef } }, + ), + ], +}; + +export const Default = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ; +}; + +export const Loading = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + new Promise(() => {}) }], + ]} + > + + + ); +}; + +export const WithError = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + + new Promise(() => { + throw new Error(); + }), + }, + ], + ]} + > + + + ); +}; + +export const WithNoResults = () => { + const [query] = useState>({ + types: ['techdocs'], + }); + + return ( + + + + ); +}; + +const CustomResultListItem = (props: any) => { + const { icon, result } = props; + + return ( + + + {icon && {icon}} + + + + ); +}; + +export const WithCustomResultItem = () => { + const [query] = useState>({ + types: ['custom'], + }); + + return ( + { + switch (type) { + case 'custom': + return ( + } + result={document} + highlight={highlight} + rank={rank} + /> + ); + default: + return ( + + ); + } + }} + /> + ); +}; diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx new file mode 100644 index 0000000000..20e5a1cba1 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; + +import { + TestApiProvider, + renderWithEffects, + wrapInTestApp, +} from '@backstage/test-utils'; + +import { searchApiRef } from '../../api'; +import { SearchResultList } from './SearchResultList'; + +const query = jest.fn().mockResolvedValue({ results: [] }); +const searchApiMock = { query }; + +describe('SearchResultList', () => { + const results = [ + { + type: 'techdocs', + document: { + location: 'search/search-result1', + title: 'Search Result 1', + text: 'Some text from the search result 1', + }, + }, + { + type: 'techdocs', + document: { + location: 'search/search-result2', + title: 'Search Result 2', + text: 'Some text from the search result 2', + }, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Renders without exploding', async () => { + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(query).toHaveBeenCalledWith({ + filters: {}, + pageCursor: undefined, + term: '', + types: ['techdocs'], + }); + }); + + it('Defines a default render result item', async () => { + query.mockResolvedValueOnce({ + results, + }); + + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(screen.getByText('Search Result 1')).toBeInTheDocument(); + expect( + screen.getByText('Some text from the search result 1'), + ).toBeInTheDocument(); + + expect(screen.getByText('Search Result 2')).toBeInTheDocument(); + expect( + screen.getByText('Some text from the search result 2'), + ).toBeInTheDocument(); + }); + + it('Shows a progress bar when loading results', async () => { + query.mockReturnValueOnce(new Promise(() => {})); + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + await waitFor(() => { + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + }); + + it('Shows an error panel when results rendering fails', async () => { + query.mockRejectedValueOnce(new Error()); + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + await waitFor(() => { + expect( + screen.getByText( + 'Error: Error encountered while fetching search results', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx new file mode 100644 index 0000000000..8f184f7080 --- /dev/null +++ b/plugins/search-react/src/components/SearchResultList/SearchResultList.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { List, ListProps } from '@material-ui/core'; + +import { + EmptyState, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; +import { AnalyticsContext } from '@backstage/core-plugin-api'; +import { SearchQuery, SearchResult } from '@backstage/plugin-search-common'; + +import { DefaultResultListItem } from '../DefaultResultListItem'; +import { SearchResultState } from '../SearchResult'; + +/** + * Props for {@link SearchResultListLayout} + * @public + */ +export type SearchResultListLayoutProps = ListProps & { + /** + * Search results to be rendered as a list. + */ + resultItems?: SearchResult[]; + /** + * Function to customize how result items are rendered. + */ + renderResultItem?: (resultItem: SearchResult) => JSX.Element; + /** + * If defined, will render a default error panel. + */ + error?: Error; + /** + * If defined, will render a default loading progress. + */ + loading?: boolean; +}; + +/** + * Default layout for rendering search results in a list. + * @param props - See {@link SearchResultListLayoutProps}. + * @public + */ +export const SearchResultListLayout = (props: SearchResultListLayoutProps) => { + const { loading, error, resultItems, renderResultItem, ...rest } = props; + + return ( + + {loading ? : null} + {!loading && error ? ( + + ) : null} + {!loading && !error && resultItems?.length + ? resultItems.map(resultItem => renderResultItem?.(resultItem) ?? null) + : null} + {!loading && !error && !resultItems?.length ? ( + + ) : null} + + ); +}; + +/** + * Props for {@link SearchResultList}. + * @public + */ +export type SearchResultListProps = Omit< + SearchResultListLayoutProps, + 'loading' | 'error' | 'resultItems' +> & { + /** + * A search query used for requesting the results to be listed. + */ + query: Partial; +}; + +/** + * Given a query, search for results and render them as a list. + * @param props - See {@link SearchResultListProps}. + * @public + */ +export const SearchResultList = (props: SearchResultListProps) => { + const { + query, + renderResultItem = ({ document }) => ( + + ), + ...rest + } = props; + + return ( + + + {({ loading, error, value }) => ( + + )} + + + ); +}; diff --git a/plugins/search-react/src/components/SearchResultList/index.tsx b/plugins/search-react/src/components/SearchResultList/index.tsx new file mode 100644 index 0000000000..64dee0dd2c --- /dev/null +++ b/plugins/search-react/src/components/SearchResultList/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { SearchResultList, SearchResultListLayout } from './SearchResultList'; + +export type { + SearchResultListProps, + SearchResultListLayoutProps, +} from './SearchResultList'; diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts index e254c36dd9..62d8b611a4 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -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'; diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index b4b5f00919..7faa743c9d 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -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(initialValue.term); + const [types, setTypes] = useState(initialValue.types); + const [filters, setFilters] = useState(initialValue.filters); const [pageCursor, setPageCursor] = useState( initialValue.pageCursor, ); - const [filters, setFilters] = useState(initialValue.filters); - const [term, setTerm] = useState(initialValue.term); - const [types, setTypes] = useState(initialValue.types); const prevTerm = usePrevious(term); @@ -121,7 +124,7 @@ const useSearchContextValue = ( pageCursor, types, }), - [term, filters, types, pageCursor], + [term, types, filters, pageCursor], ); const hasNextPage = diff --git a/plugins/search-react/src/context/index.tsx b/plugins/search-react/src/context/index.tsx index 304f762943..720af8a949 100644 --- a/plugins/search-react/src/context/index.tsx +++ b/plugins/search-react/src/context/index.tsx @@ -19,6 +19,7 @@ export { useSearch, useSearchContextCheck, } from './SearchContext'; + export type { SearchContextProviderProps, SearchContextState, diff --git a/yarn.lock b/yarn.lock index 8c36d1da02..d55434a22a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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