feat(search): create search result list item extensions
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -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 }) =>
|
||||
(
|
||||
<ListItem>
|
||||
<ListItemText primary="Default" secondary={props.result?.title} />
|
||||
</ListItem>
|
||||
),
|
||||
} = options;
|
||||
return plugin.provide(
|
||||
createSearchResultListItemExtension({
|
||||
predicate,
|
||||
component,
|
||||
name: 'TestSearchResultItemExtension',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('extensions', () => {
|
||||
it('renders without exploding', async () => {
|
||||
await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<SearchResultListItemExtensions results={results} />
|
||||
</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('capture results discovery events', async () => {
|
||||
await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<SearchResultListItemExtensions results={results} />
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<DefaultSearchResultListItemExtension result={results[0].document} />
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<SearchResultListItemExtensions results={results}>
|
||||
<DefaultSearchResultListItemExtension />
|
||||
</SearchResultListItemExtensions>
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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 }) =>
|
||||
(
|
||||
<ListItem>
|
||||
<ListItemText primary="Explore" secondary={props.result?.title} />
|
||||
</ListItem>
|
||||
),
|
||||
});
|
||||
|
||||
await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<SearchResultListItemExtensions results={results}>
|
||||
<ExploreSearchResultListItemExtension />
|
||||
<DefaultSearchResultListItemExtension />
|
||||
</SearchResultListItemExtensions>
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Box role="button" tabIndex={0} onClickCapture={handleClickCapture}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @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<Component>;
|
||||
/**
|
||||
* 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<Component>,
|
||||
): Extension<Component> => {
|
||||
const { name, component, predicate = () => true } = options;
|
||||
|
||||
return createReactExtension<Component>({
|
||||
name,
|
||||
component: {
|
||||
lazy: () =>
|
||||
component().then(
|
||||
type =>
|
||||
(props => (
|
||||
<SearchResultListItemExtension
|
||||
rank={props.rank}
|
||||
result={props.result}
|
||||
noTrack={props.noTrack}
|
||||
>
|
||||
{createElement(type, props)}
|
||||
</SearchResultListItemExtension>
|
||||
)) 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 (
|
||||
<Fragment key={key}>
|
||||
{element ?? (
|
||||
<SearchResultListItemExtension
|
||||
rank={result.rank}
|
||||
result={result.document}
|
||||
>
|
||||
<DefaultResultListItem
|
||||
rank={result.rank}
|
||||
highlight={result.highlight}
|
||||
result={result.document}
|
||||
/>
|
||||
</SearchResultListItemExtension>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
[elements],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Props for {@link SearchResultListItemExtensions}
|
||||
*/
|
||||
export type SearchResultListItemExtensionsProps = Omit<ListProps, 'results'> & {
|
||||
/**
|
||||
* 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 <List {...rest}>{results.map(render)}</List>;
|
||||
};
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
export { searchApiRef, MockSearchApi } from './api';
|
||||
export type { SearchApi } from './api';
|
||||
export * from './extensions';
|
||||
export * from './components';
|
||||
export {
|
||||
SearchContextProvider,
|
||||
|
||||
Reference in New Issue
Block a user