diff --git a/.changeset/dry-flies-rhyme.md b/.changeset/dry-flies-rhyme.md new file mode 100644 index 0000000000000..4257b7c2c52a6 --- /dev/null +++ b/.changeset/dry-flies-rhyme.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-react': patch +'@backstage/plugin-search': patch +--- + +Added new extension points to extend search filters `SearchFilterBlueprint` and `SearchFilterResultTypeBlueprint` diff --git a/plugins/search-react/report-alpha.api.md b/plugins/search-react/report-alpha.api.md index e49b0bbeee5db..9e3cc4b9b8a9a 100644 --- a/plugins/search-react/report-alpha.api.md +++ b/plugins/search-react/report-alpha.api.md @@ -17,6 +17,85 @@ export type BaseSearchResultListItemProps = T & { result?: SearchDocument; } & Omit; +// @alpha (undocumented) +export const SearchFilterBlueprint: ExtensionBlueprint<{ + kind: 'search-filter'; + name: undefined; + params: SearchFilterBlueprintParams; + output: ConfigurableExtensionDataRef< + { + component: SearchFilterExtensionComponent; + }, + 'search.filters.filter', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + searchFilters: ConfigurableExtensionDataRef< + { + component: SearchFilterExtensionComponent; + }, + 'search.filters.filter', + {} + >; + }; +}>; + +// @alpha (undocumented) +export interface SearchFilterBlueprintParams { + // (undocumented) + component: SearchFilterExtensionComponent; +} + +// @alpha (undocumented) +export type SearchFilterExtensionComponent = ( + props: SearchFilterExtensionComponentProps, +) => JSX.Element; + +// @alpha (undocumented) +export type SearchFilterExtensionComponentProps = { + className: string; +}; + +// @alpha (undocumented) +export const SearchFilterResultTypeBlueprint: ExtensionBlueprint<{ + kind: 'search-filter-result-type'; + name: undefined; + params: SearchFilterResultTypeBlueprintParams; + output: ConfigurableExtensionDataRef< + { + value: string; + name: string; + icon: JSX.Element; + }, + 'search.filters.result-types.type', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + resultType: ConfigurableExtensionDataRef< + { + value: string; + name: string; + icon: JSX.Element; + }, + 'search.filters.result-types.type', + {} + >; + }; +}>; + +// @alpha (undocumented) +export interface SearchFilterResultTypeBlueprintParams { + icon: JSX.Element; + name: string; + value: string; +} + // @alpha (undocumented) export type SearchResultItemExtensionComponent = < P extends BaseSearchResultListItemProps, diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx new file mode 100644 index 0000000000000..6bae597dbb059 --- /dev/null +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2025 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 { + PageBlueprint, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { SearchFilterBlueprint } from './SearchFilterBlueprint'; +import { searchFilterDataRef } from './types'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; + +describe('SearchFilterBlueprint', () => { + it('should return an extension', () => { + const extension = SearchFilterBlueprint.make({ + name: 'test', + params: { + component: props =>

test filter

, + }, + }); + + expect(extension).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "T": undefined, + "attachTo": { + "id": "page:search", + "input": "searchFilters", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": "search-filter", + "name": "test", + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); + }); + + it('should render filter components', async () => { + const extension = SearchFilterBlueprint.make({ + name: 'test', + params: { + component: props =>

Test Filter

, + }, + }); + + const searchPage = PageBlueprint.makeWithOverrides({ + name: 'search', + inputs: { + searchFilters: createExtensionInput([searchFilterDataRef]), + }, + factory(originalFactory, { inputs }) { + return originalFactory({ + defaultPath: '/', + loader: async () => { + const searchFilters = inputs.searchFilters.map( + t => t.get(searchFilterDataRef).component, + ); + return ( +
+ {searchFilters.map((Component, index) => ( + + ))} +
+ ); + }, + }); + }, + }); + + await expect( + renderInTestApp( + createExtensionTester(searchPage).add(extension).reactElement(), + ).findByText('Test Filter'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.tsx new file mode 100644 index 0000000000000..1faacaf008c3d --- /dev/null +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterBlueprint.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2025 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 { createExtensionBlueprint } from '@backstage/frontend-plugin-api'; +import { searchFilterDataRef, SearchFilterExtensionComponent } from './types'; + +/** @alpha */ +export interface SearchFilterBlueprintParams { + component: SearchFilterExtensionComponent; +} + +/** + * @alpha + */ +export const SearchFilterBlueprint = createExtensionBlueprint({ + kind: 'search-filter', + attachTo: { + id: 'page:search', + input: 'searchFilters', + }, + output: [searchFilterDataRef], + dataRefs: { + searchFilters: searchFilterDataRef, + }, + *factory(params: SearchFilterBlueprintParams) { + yield searchFilterDataRef({ + component: params.component, + }); + }, +}); diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx new file mode 100644 index 0000000000000..b2de0319c629c --- /dev/null +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2025 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 { + PageBlueprint, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { SearchFilterResultTypeBlueprint } from './SearchFilterResultTypeBlueprint'; +import { searchResultTypeDataRef } from './types'; +import { + createExtensionTester, + renderInTestApp, +} from '@backstage/frontend-test-utils'; + +describe('SearchFilterResultTypeBlueprint', () => { + it('should return an extension', () => { + const extension = SearchFilterResultTypeBlueprint.make({ + name: 'test', + params: { + value: 'test', + name: 'Test', + icon:
Hello
, + }, + }); + + expect(extension).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "T": undefined, + "attachTo": { + "id": "page:search", + "input": "resultTypes", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": "search-filter-result-type", + "name": "test", + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); + }); + + it('should render result types', async () => { + const extension = SearchFilterResultTypeBlueprint.make({ + name: 'test', + params: { + value: 'test', + name: 'Test Result Type', + icon:
Hello
, + }, + }); + + const searchPage = PageBlueprint.makeWithOverrides({ + name: 'search', + inputs: { + resultTypes: createExtensionInput([searchResultTypeDataRef]), + }, + factory(originalFactory, { inputs }) { + return originalFactory({ + defaultPath: '/', + loader: async () => { + const resultTypes = inputs.resultTypes.map(t => + t.get(searchResultTypeDataRef), + ); + return ( +
+ {resultTypes.map((t, i) => ( +
{t.name}
+ ))} +
+ ); + }, + }); + }, + }); + + await expect( + renderInTestApp( + createExtensionTester(searchPage).add(extension).reactElement(), + ).findByText('Test Result Type'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.tsx b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.tsx new file mode 100644 index 0000000000000..fa321daa73a88 --- /dev/null +++ b/plugins/search-react/src/alpha/blueprints/SearchFilterResultTypeBlueprint.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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 { createExtensionBlueprint } from '@backstage/frontend-plugin-api'; +import { searchResultTypeDataRef } from './types'; + +/** @alpha */ +export interface SearchFilterResultTypeBlueprintParams { + /** + * The value of the result type. + */ + value: string; + /** + * The name of the result type. + */ + name: string; + /** + * The icon of the result type. + */ + icon: JSX.Element; +} + +/** + * @alpha + */ +export const SearchFilterResultTypeBlueprint = createExtensionBlueprint({ + kind: 'search-filter-result-type', + attachTo: { + id: 'page:search', + input: 'resultTypes', + }, + output: [searchResultTypeDataRef], + dataRefs: { + resultType: searchResultTypeDataRef, + }, + *factory(params: SearchFilterResultTypeBlueprintParams) { + yield searchResultTypeDataRef({ + value: params.value, + name: params.name, + icon: params.icon, + }); + }, +}); diff --git a/plugins/search-react/src/alpha/blueprints/index.ts b/plugins/search-react/src/alpha/blueprints/index.ts index b9bbf1012e8d9..f89ecebdb198a 100644 --- a/plugins/search-react/src/alpha/blueprints/index.ts +++ b/plugins/search-react/src/alpha/blueprints/index.ts @@ -22,4 +22,16 @@ export { type BaseSearchResultListItemProps, type SearchResultItemExtensionComponent, type SearchResultItemExtensionPredicate, + type SearchFilterExtensionComponent, + type SearchFilterExtensionComponentProps, } from './types'; + +export { + SearchFilterResultTypeBlueprint, + type SearchFilterResultTypeBlueprintParams, +} from './SearchFilterResultTypeBlueprint'; + +export { + SearchFilterBlueprint, + type SearchFilterBlueprintParams, +} from './SearchFilterBlueprint'; diff --git a/plugins/search-react/src/alpha/blueprints/types.ts b/plugins/search-react/src/alpha/blueprints/types.ts index abfed4aba6b3f..56306ba681d32 100644 --- a/plugins/search-react/src/alpha/blueprints/types.ts +++ b/plugins/search-react/src/alpha/blueprints/types.ts @@ -36,7 +36,30 @@ export type SearchResultItemExtensionPredicate = ( result: SearchResult, ) => boolean; +/** @alpha */ export const searchResultListItemDataRef = createExtensionDataRef<{ predicate?: SearchResultItemExtensionPredicate; component: SearchResultItemExtensionComponent; }>().with({ id: 'search.search-result-list-item.item' }); + +/** @alpha */ +export const searchResultTypeDataRef = createExtensionDataRef<{ + value: string; + name: string; + icon: JSX.Element; +}>().with({ id: 'search.filters.result-types.type' }); + +/** @alpha */ +export type SearchFilterExtensionComponentProps = { + className: string; +}; + +/** @alpha */ +export type SearchFilterExtensionComponent = ( + props: SearchFilterExtensionComponentProps, +) => JSX.Element; + +/** @alpha */ +export const searchFilterDataRef = createExtensionDataRef<{ + component: SearchFilterExtensionComponent; +}>().with({ id: 'search.filters.filter' }); diff --git a/plugins/search/report-alpha.api.md b/plugins/search/report-alpha.api.md index d4a016bbe248b..7581e1c25ff8e 100644 --- a/plugins/search/report-alpha.api.md +++ b/plugins/search/report-alpha.api.md @@ -12,6 +12,7 @@ import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; +import { SearchFilterExtensionComponent } from '@backstage/plugin-search-react/alpha'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha'; @@ -98,6 +99,34 @@ const _default: FrontendPlugin< optional: false; } >; + resultTypes: ExtensionInput< + ConfigurableExtensionDataRef< + { + value: string; + name: string; + icon: JSX.Element; + }, + 'search.filters.result-types.type', + {} + >, + { + singleton: false; + optional: false; + } + >; + searchFilters: ExtensionInput< + ConfigurableExtensionDataRef< + { + component: SearchFilterExtensionComponent; + }, + 'search.filters.filter', + {} + >, + { + singleton: false; + optional: false; + } + >; }; kind: 'page'; name: undefined; @@ -184,6 +213,34 @@ export const searchPage: ExtensionDefinition<{ optional: false; } >; + resultTypes: ExtensionInput< + ConfigurableExtensionDataRef< + { + value: string; + name: string; + icon: JSX.Element; + }, + 'search.filters.result-types.type', + {} + >, + { + singleton: false; + optional: false; + } + >; + searchFilters: ExtensionInput< + ConfigurableExtensionDataRef< + { + component: SearchFilterExtensionComponent; + }, + 'search.filters.filter', + {} + >, + { + singleton: false; + optional: false; + } + >; }; kind: 'page'; name: undefined; diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index 90fc394f9dea7..a6de7c680ec01 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -61,7 +61,11 @@ import { } from '@backstage/plugin-search-react'; import { SearchResult } from '@backstage/plugin-search-common'; import { searchApiRef } from '@backstage/plugin-search-react'; -import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; +import { + SearchResultListItemBlueprint, + SearchFilterResultTypeBlueprint, + SearchFilterBlueprint, +} from '@backstage/plugin-search-react/alpha'; import { rootRouteRef } from './plugin'; import { SearchClient } from './apis'; @@ -106,6 +110,12 @@ export const searchPage = PageBlueprint.makeWithOverrides({ }, inputs: { items: createExtensionInput([SearchResultListItemBlueprint.dataRefs.item]), + resultTypes: createExtensionInput([ + SearchFilterResultTypeBlueprint.dataRefs.resultType, + ]), + searchFilters: createExtensionInput([ + SearchFilterBlueprint.dataRefs.searchFilters, + ]), }, factory(originalFactory, { config, inputs }) { return originalFactory({ @@ -124,6 +134,15 @@ export const searchPage = PageBlueprint.makeWithOverrides({ ); }; + const resultTypes = inputs.resultTypes.map(item => + item.get(SearchFilterResultTypeBlueprint.dataRefs.resultType), + ); + + const additionalSearchFilters = inputs.searchFilters.map( + item => + item.get(SearchFilterBlueprint.dataRefs.searchFilters).component, + ); + const Component = () => { const classes = useSearchPageStyles(); const { isMobile } = useSidebarPinState(); @@ -155,7 +174,7 @@ export const searchPage = PageBlueprint.makeWithOverrides({ name: 'Documentation', icon: , }, - ]} + ].concat(resultTypes)} /> {types.includes('techdocs') && ( @@ -193,6 +212,9 @@ export const searchPage = PageBlueprint.makeWithOverrides({ name="lifecycle" values={['experimental', 'production']} /> + {additionalSearchFilters.map(SearchFilterComponent => ( + + ))} )}