feat(search): create search result list item extensions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-01-22 19:30:41 +01:00
parent e9e5da25b8
commit 098cf0f8c0
3 changed files with 448 additions and 0 deletions
@@ -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();
});
});
+238
View File
@@ -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>;
};
+1
View File
@@ -22,6 +22,7 @@
export { searchApiRef, MockSearchApi } from './api';
export type { SearchApi } from './api';
export * from './extensions';
export * from './components';
export {
SearchContextProvider,