diff --git a/plugins/search-react/report-alpha.api.md b/plugins/search-react/report-alpha.api.md index d8d0dbac97..9e3cc4b9b8 100644 --- a/plugins/search-react/report-alpha.api.md +++ b/plugins/search-react/report-alpha.api.md @@ -17,6 +17,48 @@ 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'; 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 0000000000..6bae597dbb --- /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 0000000000..1faacaf008 --- /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/index.ts b/plugins/search-react/src/alpha/blueprints/index.ts index ca78a6d603..f89ecebdb1 100644 --- a/plugins/search-react/src/alpha/blueprints/index.ts +++ b/plugins/search-react/src/alpha/blueprints/index.ts @@ -22,9 +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 80888203a0..56306ba681 100644 --- a/plugins/search-react/src/alpha/blueprints/types.ts +++ b/plugins/search-react/src/alpha/blueprints/types.ts @@ -36,13 +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 885e6453f0..e92ce18cad 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'; @@ -92,6 +93,19 @@ const _default: FrontendPlugin< optional: false; } >; + searchFilters: ExtensionInput< + ConfigurableExtensionDataRef< + { + component: SearchFilterExtensionComponent; + }, + 'search.filters.filter', + {} + >, + { + singleton: false; + optional: false; + } + >; }; kind: 'page'; name: undefined; @@ -214,6 +228,19 @@ export const searchPage: ExtensionDefinition<{ 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 8bbcfb1048..a6de7c680e 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -64,6 +64,7 @@ import { searchApiRef } from '@backstage/plugin-search-react'; import { SearchResultListItemBlueprint, SearchFilterResultTypeBlueprint, + SearchFilterBlueprint, } from '@backstage/plugin-search-react/alpha'; import { rootRouteRef } from './plugin'; @@ -112,6 +113,9 @@ export const searchPage = PageBlueprint.makeWithOverrides({ resultTypes: createExtensionInput([ SearchFilterResultTypeBlueprint.dataRefs.resultType, ]), + searchFilters: createExtensionInput([ + SearchFilterBlueprint.dataRefs.searchFilters, + ]), }, factory(originalFactory, { config, inputs }) { return originalFactory({ @@ -134,6 +138,11 @@ export const searchPage = PageBlueprint.makeWithOverrides({ 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(); @@ -203,6 +212,9 @@ export const searchPage = PageBlueprint.makeWithOverrides({ name="lifecycle" values={['experimental', 'production']} /> + {additionalSearchFilters.map(SearchFilterComponent => ( + + ))} )}