diff --git a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx index 8ac8aace0f..4b8d9bbdd4 100644 --- a/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx +++ b/plugins/search-react/src/components/SearchResult/SearchResult.stories.tsx @@ -18,6 +18,8 @@ import React, { ComponentType } from 'react'; 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'; @@ -30,6 +32,7 @@ import { DefaultResultListItem } from '../DefaultResultListItem'; import { SearchResultListLayout } from '../SearchResultList'; import { SearchResult } from './SearchResult'; +import { SearchResultGroupLayout } from '../SearchResultGroup'; const mockResults = { results: [ @@ -180,3 +183,38 @@ export const ListLayout = () => { ); }; + +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/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/index.ts b/plugins/search-react/src/components/index.ts index 6cf7a22a7d..62d8b611a4 100644 --- a/plugins/search-react/src/components/index.ts +++ b/plugins/search-react/src/components/index.ts @@ -21,4 +21,5 @@ export * from './SearchFilter'; export * from './SearchResult'; export * from './SearchResultPager'; export * from './SearchResultList'; +export * from './SearchResultGroup'; export * from './DefaultResultListItem';