diff --git a/plugins/search-react/src/extensions.test.tsx b/plugins/search-react/src/extensions.test.tsx new file mode 100644 index 0000000000..24c115e572 --- /dev/null +++ b/plugins/search-react/src/extensions.test.tsx @@ -0,0 +1,209 @@ +/* + * Copyright 2023 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 } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ListItem, ListItemText } from '@material-ui/core'; + +import { + wrapInTestApp, + renderWithEffects, + TestApiProvider, + MockAnalyticsApi, +} from '@backstage/test-utils'; +import { + createPlugin, + BackstagePlugin, + analyticsApiRef, +} from '@backstage/core-plugin-api'; +import { SearchResult, SearchDocument } from '@backstage/plugin-search-common'; + +import { + SearchResultListItemExtensions, + createSearchResultListItemExtension, + SearchResultListItemExtensionOptions, +} from './extensions'; + +const analyticsApiMock = new MockAnalyticsApi(); + +const results = [ + { + type: 'explore', + 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', + }, + }, +]; + +const createExtension = ( + plugin: BackstagePlugin, + options: Partial< + Omit< + SearchResultListItemExtensionOptions< + (props: { result?: SearchDocument }) => JSX.Element | null + >, + 'name' + > + > = {}, +) => { + const { + predicate, + component = async () => (props: { result?: SearchDocument }) => + ( + + + + ), + } = options; + return plugin.provide( + createSearchResultListItemExtension({ + predicate, + component, + name: 'TestSearchResultItemExtension', + }), + ); +}; + +describe('extensions', () => { + it('renders without exploding', async () => { + 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('capture results discovery events', async () => { + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + await userEvent.click( + screen.getByRole('button', { name: /Search Result 1/ }), + ); + + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ + action: 'discover', + subject: 'Search Result 1', + context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' }, + attributes: { to: 'search/search-result1' }, + }); + }); + + it('Could be used as simple components', async () => { + const plugin = createPlugin({ id: 'plugin' }); + const DefaultSearchResultListItemExtension = createExtension(plugin); + + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByText('Search Result 1')).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: /Search Result 1/ }), + ); + + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ + action: 'discover', + subject: 'Search Result 1', + context: { routeRef: 'unknown', pluginId: 'root', extension: 'App' }, + attributes: { to: 'search/search-result1' }, + }); + }); + + it('use default options for rendering results', async () => { + const plugin = createPlugin({ id: 'plugin' }); + const DefaultSearchResultListItemExtension = createExtension(plugin); + + await renderWithEffects( + wrapInTestApp( + + + + + , + ), + ); + + expect(screen.getAllByText('Default')).toHaveLength(2); + expect(screen.getByText('Search Result 1')).toBeInTheDocument(); + expect(screen.getByText('Search Result 2')).toBeInTheDocument(); + }); + + it('use custom options for rendering results', async () => { + const plugin = createPlugin({ id: 'plugin' }); + const DefaultSearchResultListItemExtension = createExtension(plugin); + const ExploreSearchResultListItemExtension = createExtension(plugin, { + predicate: (result: SearchResult) => result.type === 'explore', + component: async () => (props: { result?: SearchDocument }) => + ( + + + + ), + }); + + await renderWithEffects( + wrapInTestApp( + + + + + + , + ), + ); + + expect(screen.getAllByText('Default')).toHaveLength(1); + expect(screen.getAllByText('Explore')).toHaveLength(1); + expect(screen.getByText('Search Result 1')).toBeInTheDocument(); + expect(screen.getByText('Search Result 2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx new file mode 100644 index 0000000000..1e0619b14c --- /dev/null +++ b/plugins/search-react/src/extensions.tsx @@ -0,0 +1,238 @@ +/* + * Copyright 2023 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, { + Fragment, + ReactNode, + PropsWithChildren, + isValidElement, + createElement, + cloneElement, + useCallback, +} from 'react'; + +import { + getComponentData, + useElementFilter, + Extension, + createReactExtension, + useAnalytics, +} from '@backstage/core-plugin-api'; +import { SearchDocument, SearchResult } from '@backstage/plugin-search-common'; + +import { Box, List, ListProps } from '@material-ui/core'; + +import { DefaultResultListItem } from './components'; + +/** + * @internal + * Key for result extensions. + */ +const SEARCH_RESULT_LIST_ITEM_EXTENSION = + 'search.results.list.items.extensions.v1'; + +/** + * @internal + * Returns the first extension element found for a given result, and null otherwise. + * @param elements - All extension elements. + * @param result - The search result. + */ +const findSearchResultListItemExtensionElement = ( + elements: ReactNode[], + result: SearchResult, +) => { + for (const element of elements) { + if (!isValidElement(element)) continue; + const predicate = getComponentData<(result: SearchResult) => boolean>( + element, + SEARCH_RESULT_LIST_ITEM_EXTENSION, + ); + if (!predicate?.(result)) continue; + return cloneElement(element, { + rank: result.rank, + highlight: result.highlight, + result: result.document, + // Use props in situations where a consumer is manually rendering the extension + ...element.props, + }); + } + return null; +}; + +/** + * @internal + * Props for {@link SearchResultListItemExtension}. + */ +type SearchResultListItemExtensionProps = PropsWithChildren<{ + rank?: number; + result?: SearchDocument; + noTrack?: boolean; +}>; + +/** + * @internal + * Extends children with extension capabilities. + * @param props - see {@link SearchResultListItemExtensionProps}. + */ +const SearchResultListItemExtension = ({ + rank, + result, + noTrack, + children, +}: SearchResultListItemExtensionProps) => { + const analytics = useAnalytics(); + + const handleClickCapture = useCallback(() => { + if (noTrack) return; + if (!result) return; + analytics.captureEvent('discover', result.title, { + attributes: { to: result.location }, + value: rank, + }); + }, [rank, result, noTrack, analytics]); + + return ( + + {children} + + ); +}; + +/** + * @public + * Options for {@link createSearchResultListItemExtension}. + */ +export type SearchResultListItemExtensionOptions< + Component extends (props: any) => JSX.Element | null, +> = { + /** + * The extension name. + */ + name: string; + /** + * The extension component. + */ + component: () => Promise; + /** + * When an extension defines a predicate, it returns true if the result should be rendered by that extension. + * Defaults to a predicate that returns true, which means it renders all sorts of results. + */ + predicate?: (result: SearchResult) => boolean; +}; + +/** + * @public + * Creates a search result item extension. + * @param options - The extension options, see {@link SearchResultListItemExtensionOptions} for more details. + */ +export const createSearchResultListItemExtension = < + Component extends (props: any) => JSX.Element | null, +>( + options: SearchResultListItemExtensionOptions, +): Extension => { + const { name, component, predicate = () => true } = options; + + return createReactExtension({ + name, + component: { + lazy: () => + component().then( + type => + (props => ( + + {createElement(type, props)} + + )) as Component, + ), + }, + data: { + [SEARCH_RESULT_LIST_ITEM_EXTENSION]: predicate, + }, + }); +}; + +/** + * @public + * Returns a function that renders a result using extensions. + */ +export const useSearchResultListItemExtensions = (children: ReactNode) => { + const elements = useElementFilter( + children, + collection => { + return collection + .selectByComponentData({ + key: SEARCH_RESULT_LIST_ITEM_EXTENSION, + }) + .getElements(); + }, + [children], + ); + + return useCallback( + (result: SearchResult, key?: number) => { + const element = findSearchResultListItemExtensionElement( + elements, + result, + ); + + return ( + + {element ?? ( + + + + )} + + ); + }, + [elements], + ); +}; + +/** + * @public + * Props for {@link SearchResultListItemExtensions} + */ +export type SearchResultListItemExtensionsProps = Omit & { + /** + * Search result list. + */ + results: SearchResult[]; +}; + +/** + * @public + * Render results using search extensions. + * @param props - see {@link SearchResultListItemExtensionsProps} + */ +export const SearchResultListItemExtensions = ( + props: SearchResultListItemExtensionsProps, +) => { + const { results, children, ...rest } = props; + const render = useSearchResultListItemExtensions(children); + return {results.map(render)}; +}; diff --git a/plugins/search-react/src/index.ts b/plugins/search-react/src/index.ts index 9813661f3e..8b234e3c05 100644 --- a/plugins/search-react/src/index.ts +++ b/plugins/search-react/src/index.ts @@ -22,6 +22,7 @@ export { searchApiRef, MockSearchApi } from './api'; export type { SearchApi } from './api'; +export * from './extensions'; export * from './components'; export { SearchContextProvider,