;
+ predicate?: SearchResultItemExtensionPredicate;
+};
+
+// @alpha (undocumented)
+export type SearchResultItemExtensionPredicate = (
+ result: SearchResult,
+) => boolean;
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json
index 4454d44574..da4092fcbd 100644
--- a/plugins/search-react/package.json
+++ b/plugins/search-react/package.json
@@ -5,9 +5,22 @@
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
- "access": "public",
- "main": "dist/index.esm.js",
- "types": "dist/index.d.ts"
+ "access": "public"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./alpha": "./src/alpha.tsx",
+ "./package.json": "./package.json"
+ },
+ "typesVersions": {
+ "*": {
+ "alpha": [
+ "src/alpha.tsx"
+ ],
+ "package.json": [
+ "package.json"
+ ]
+ }
},
"backstage": {
"role": "web-library"
@@ -34,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.test.tsx b/plugins/search-react/src/alpha.test.tsx
new file mode 100644
index 0000000000..b61a400832
--- /dev/null
+++ b/plugins/search-react/src/alpha.test.tsx
@@ -0,0 +1,206 @@
+/*
+ * 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,
+ createSchemaFromZod,
+} 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 as searchResultListItemExtensionData,
+} from './alpha';
+
+// 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 () => {
+ type TechDocsSearchReasulListItemProps = BaseSearchResultListItemProps<{
+ lineClamp: number;
+ }>;
+ const TechDocsSearchResultItemComponent = (
+ props: TechDocsSearchReasulListItemProps,
+ ) => (
+
+ TechDocs - Rank: {props.rank} - Line clamp: {props.lineClamp}
+
+ );
+
+ const TechDocsSearchResultItemExtension =
+ createSearchResultListItemExtension({
+ id: 'techdocs',
+ at: 'plugin.search.page/items',
+ configSchema: createSchemaFromZod(z =>
+ z.object({
+ noTrack: z.boolean().default(true),
+ lineClamp: z.number().default(5),
+ }),
+ ),
+ predicate: result => result.type === 'techdocs',
+ component:
+ async ({ config }) =>
+ props =>
+ ,
+ });
+
+ 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: searchResultListItemExtensionData,
+ }),
+ },
+ 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
+
+ {results.map((result, index) => {
+ const SearchResultListItem = getResultItemComponent(result);
+ return (
+
+ );
+ })}
+
+
+ );
+ };
+
+ return ;
+ },
+ });
+
+ const SearchPlugin = createPlugin({
+ id: 'search.plugin',
+ extensions: [
+ SearchPageExtension,
+ ExploreSearchResultItemExtension,
+ TechDocsSearchResultItemExtension,
+ ],
+ });
+
+ const app = createApp({
+ plugins: [SearchPlugin],
+ configLoader: async () =>
+ new MockConfigApi({
+ app: {
+ extensions: [
+ {
+ 'plugin.search.result.item.techdocs': {
+ config: {
+ lineClamp: 3,
+ },
+ },
+ },
+ ],
+ },
+ }),
+ });
+
+ render(app.createRoot());
+
+ expect(await screen.findByText(/Search Page/)).toBeInTheDocument();
+
+ expect(
+ await screen.findByText(/TechDocs - Rank: 1 - Line clamp: 3/, {
+ 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/alpha.tsx b/plugins/search-react/src/alpha.tsx
new file mode 100644
index 0000000000..4b5bb79669
--- /dev/null
+++ b/plugins/search-react/src/alpha.tsx
@@ -0,0 +1,132 @@
+/*
+ * 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,
+ PortableSchema,
+ createExtension,
+ createExtensionDataRef,
+ createSchemaFromZod,
+} 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 BaseSearchResultListItemProps = T & {
+ rank?: number;
+ result?: SearchDocument;
+} & Omit;
+
+/** @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<
+ TConfig extends { noTrack?: boolean },
+> = {
+ /**
+ * The extension id.
+ */
+ id: string;
+ /**
+ * The extension attachment point (e.g., search modal or page).
+ */
+ at: string;
+ /**
+ * Optional extension config schema.
+ */
+ configSchema?: PortableSchema;
+ /**
+ * The extension component.
+ */
+ component: (options: {
+ config: TConfig;
+ }) => 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 function createSearchResultListItemExtension<
+ TConfig extends { noTrack?: boolean },
+>(options: SearchResultItemExtensionOptions) {
+ const configSchema =
+ 'configSchema' in options
+ ? options.configSchema
+ : (createSchemaFromZod(z =>
+ z.object({
+ noTrack: z.boolean().default(false),
+ }),
+ ) as PortableSchema);
+ return createExtension({
+ id: `plugin.search.result.item.${options.id}`,
+ at: options.at,
+ configSchema,
+ output: {
+ item: searchResultItemExtensionData,
+ },
+ factory({ bind, config, source }) {
+ const LazyComponent = lazy(() =>
+ options
+ .component({ config })
+ .then(component => ({ default: component })),
+ ) as unknown as SearchResultItemExtensionComponent;
+
+ bind({
+ item: {
+ predicate: options.predicate,
+ component: props => (
+
+ }>
+
+
+
+
+
+ ),
+ },
+ });
+ },
+ });
+}
diff --git a/plugins/search-react/src/extensions.tsx b/plugins/search-react/src/extensions.tsx
index a29311b1ff..132fad25b5 100644
--- a/plugins/search-react/src/extensions.tsx
+++ b/plugins/search-react/src/extensions.tsx
@@ -90,7 +90,7 @@ export type SearchResultListItemExtensionProps = Props &
* Extends children with extension capabilities.
* @param props - see {@link SearchResultListItemExtensionProps}.
*/
-const SearchResultListItemExtension = (
+export const SearchResultListItemExtension = (
props: SearchResultListItemExtensionProps,
) => {
const {
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:^"