diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 6255ccb0ff..31e12db763 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -47,6 +47,8 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-app-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", diff --git a/plugins/search-react/src/alpha.ts b/plugins/search-react/src/alpha.ts index eb2f165350..e006e249eb 100644 --- a/plugins/search-react/src/alpha.ts +++ b/plugins/search-react/src/alpha.ts @@ -13,3 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +export * from './wiring'; diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx index a29311b1ff..5cd0aab76e 100644 --- a/plugins/search-react/src/extensions.tsx +++ b/plugins/search-react/src/extensions.tsx @@ -86,11 +86,11 @@ export type SearchResultListItemExtensionProps = Props & >; /** - * @internal + * @public * Extends children with extension capabilities. * @param props - see {@link SearchResultListItemExtensionProps}. */ -const SearchResultListItemExtension = ( +export const SearchResultListItemExtension = ( props: SearchResultListItemExtensionProps, ) => { const { diff --git a/plugins/search-react/src/wiring/createSearchResultListItemExtension.test.tsx b/plugins/search-react/src/wiring/createSearchResultListItemExtension.test.tsx new file mode 100644 index 0000000000..d7a2947e1f --- /dev/null +++ b/plugins/search-react/src/wiring/createSearchResultListItemExtension.test.tsx @@ -0,0 +1,175 @@ +/* + * 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 { render, screen } from '@testing-library/react'; + +import { + createExtensionInput, + createPageExtension, + createPlugin, +} from '@backstage/frontend-plugin-api'; +import { SearchResult } from '@backstage/plugin-search-common'; +import { createApp } from '@backstage/frontend-app-api'; +import { MockConfigApi } from '@backstage/test-utils'; + +import { + BaseSearchResultListItemProps, + createSearchResultListItemExtension, + searchResultItemExtensionData, +} from './createSearchResultListItemExtension'; + +// TODO: Remove this mock when we have a permanent solution for nav items extensions +// The `GraphiQLIcon` used in "packages/frontend-app-api/src/extensions/CoreNav.tsx" file +// is throwing a "ReferenceError: ref is not defined" error during test +jest.mock('@backstage/plugin-graphiql', () => ({ + ...jest.requireActual('@backstage/plugin-graphiql'), + GraphiQLIcon: () => null, +})); + +describe('createSearchResultListItemExtension', () => { + it('Should use the correct result component', async () => { + const TechDocsSearchResultItemComponent = ( + props: BaseSearchResultListItemProps, + ) =>
TechDocs - Rank: {props.rank}
; + + const TechDocsSearchResultItemExtension = + createSearchResultListItemExtension({ + id: 'techdocs', + at: 'plugin.search.page/items', + predicate: result => result.type === 'techdocs', + component: async () => TechDocsSearchResultItemComponent, + }); + + const ExploreSearchResultItemComponent = ( + props: BaseSearchResultListItemProps, + ) =>
Explore - Rank: {props.rank}
; + + const ExploreSearchResultItemExtension = + createSearchResultListItemExtension({ + id: 'explore', + at: 'plugin.search.page/items', + predicate: result => result.type === 'explore', + component: async () => ExploreSearchResultItemComponent, + }); + + const SearchPageExtension = createPageExtension({ + id: 'plugin.search.page', + defaultPath: '/', + inputs: { + items: createExtensionInput({ + item: searchResultItemExtensionData, + }), + }, + loader: async ({ inputs }) => { + const results = [ + { + type: 'techdocs', + rank: 1, + document: { + title: 'Title1', + text: 'Text1', + location: '/location1', + }, + }, + { + type: 'explore', + rank: 2, + document: { + title: 'Title2', + text: 'Text2', + location: '/location2', + }, + }, + { + type: 'other', + rank: 3, + document: { + title: 'Title3', + text: 'Text3', + location: '/location3', + }, + }, + ]; + + const DefaultResultItem = (props: BaseSearchResultListItemProps) => ( +
Default - Rank: {props.rank}
+ ); + + const getResultItemComponent = (result: SearchResult) => { + const value = inputs.items.find(({ item }) => + item?.predicate?.(result), + ); + return value?.item.component ?? DefaultResultItem; + }; + + const Component = () => { + return ( +
+

Search Page

+ +
+ ); + }; + + return ; + }, + }); + + const SearchPlugin = createPlugin({ + id: 'search.plugin', + extensions: [ + SearchPageExtension, + ExploreSearchResultItemExtension, + TechDocsSearchResultItemExtension, + ], + }); + + const app = createApp({ + plugins: [SearchPlugin], + configLoader: async () => new MockConfigApi({}), + }); + + render(app.createRoot()); + + expect(await screen.findByText(/Search Page/)).toBeInTheDocument(); + + expect( + await screen.findByText(/TechDocs - Rank: 1/, { exact: false }), + ).toBeInTheDocument(); + + expect( + await screen.findByText(/Explore - Rank: 2/, { exact: false }), + ).toBeInTheDocument(); + + expect( + await screen.findByText(/Default - Rank: 3/, { exact: false }), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/search-react/src/wiring/createSearchResultListItemExtension.tsx b/plugins/search-react/src/wiring/createSearchResultListItemExtension.tsx new file mode 100644 index 0000000000..4ad0e54e1f --- /dev/null +++ b/plugins/search-react/src/wiring/createSearchResultListItemExtension.tsx @@ -0,0 +1,111 @@ +/* + * 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, { lazy, Suspense } from 'react'; + +import { ListItemProps } from '@material-ui/core'; + +import { + ExtensionBoundary, + createExtension, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; +import { Progress } from '@backstage/core-components'; +import { SearchDocument, SearchResult } from '@backstage/plugin-search-common'; + +import { SearchResultListItemExtension } from '../extensions'; + +/** @alpha */ +export type SearchResultItemExtensionComponent = < + P extends BaseSearchResultListItemProps, +>( + props: P, +) => JSX.Element | null; + +/** @alpha */ +export type SearchResultItemExtensionPredicate = ( + result: SearchResult, +) => boolean; + +/** @alpha */ +export const searchResultItemExtensionData = createExtensionDataRef<{ + predicate?: SearchResultItemExtensionPredicate; + component: SearchResultItemExtensionComponent; +}>('plugin.search.result.item.data'); + +/** @alpha */ +export type SearchResultItemExtensionOptions = { + /** + * The extension id. + */ + id: string; + /** + * The extension attachment point (e.g., search modal or page). + */ + at: 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?: SearchResultItemExtensionPredicate; +}; + +/** @alpha */ +export type BaseSearchResultListItemProps = { + rank?: number; + result?: SearchDocument; + noTrack?: boolean; +} & Omit; + +/** @alpha */ +export const createSearchResultListItemExtension = ( + options: SearchResultItemExtensionOptions, +) => + createExtension({ + id: `plugin.search.result.item.${options.id}`, + at: options.at, + output: { + item: searchResultItemExtensionData, + }, + factory({ bind, source }) { + const LazyComponent = lazy(() => + options.component().then(component => ({ default: component })), + ) as unknown as SearchResultItemExtensionComponent; + + bind({ + item: { + predicate: options.predicate, + component: props => ( + + }> + + + + + + ), + }, + }); + }, + }); diff --git a/plugins/search-react/src/wiring/index.ts b/plugins/search-react/src/wiring/index.ts new file mode 100644 index 0000000000..7cf5b0d6f5 --- /dev/null +++ b/plugins/search-react/src/wiring/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './createSearchResultListItemExtension'; diff --git a/yarn.lock b/yarn.lock index 6f1a40c8c5..e429806816 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9079,6 +9079,8 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-app-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^"