diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 229a9446c2..1385d73691 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -319,6 +319,8 @@ periodically. For more details on extension overrides and the different override patterns available, see the [extension overrides](../../frontend-system/architecture/25-extension-overrides.md) documentation. +## Entity page + ### Entity filters Many extensions that attach within the catalog entity pages accept a `filter` configuration. The purpose of the `filter` configuration is to select what entities the extension should be applied to or be present on. Many of these extension will have a default filter defined, but you can override it by providing your own. When defining filters in code you can use either a predicate function or a entity predicate query, while in configuration you can only use an entity predicate query. diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts index 260182acb5..182b0f39c8 100644 --- a/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts +++ b/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts @@ -15,4 +15,4 @@ */ export * from './useStreamingExport'; export * from './serializeEntities'; -export * from './filtersToStreamRequest'; +export * from './toStreamRequest'; diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.test.ts similarity index 61% rename from plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts rename to plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.test.ts index b383f99cc0..3f1971d17d 100644 --- a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts +++ b/plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.test.ts @@ -13,14 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { filtersToStreamRequest } from './filtersToStreamRequest'; -import { DefaultEntityFilters } from '@backstage/plugin-catalog-react'; +import { toStreamRequest } from './toStreamRequest'; +import { + DefaultEntityFilters, + EntityTextFilter, + EntityOrderFilter, +} from '@backstage/plugin-catalog-react'; -describe('filtersToStreamRequest', () => { +describe('toStreamRequest', () => { describe('with no filters', () => { it('returns undefined for empty filters object', () => { const filters: DefaultEntityFilters = {}; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toBeUndefined(); }); @@ -38,7 +42,7 @@ describe('filtersToStreamRequest', () => { kind: mockFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -65,7 +69,7 @@ describe('filtersToStreamRequest', () => { type: mockTypeFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -86,7 +90,7 @@ describe('filtersToStreamRequest', () => { kind: mockFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -111,7 +115,7 @@ describe('filtersToStreamRequest', () => { text: nonBackendFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -136,7 +140,7 @@ describe('filtersToStreamRequest', () => { owners: mockFilterWithNull as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -157,7 +161,7 @@ describe('filtersToStreamRequest', () => { kind: mockFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -179,7 +183,7 @@ describe('filtersToStreamRequest', () => { kind: mockFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -198,7 +202,7 @@ describe('filtersToStreamRequest', () => { type: mockEmptyFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toBeUndefined(); }); @@ -215,7 +219,7 @@ describe('filtersToStreamRequest', () => { type: mockFilter as any, }; - const result = filtersToStreamRequest(filters); + const result = toStreamRequest(filters); expect(result).toEqual({ filter: { @@ -225,4 +229,99 @@ describe('filtersToStreamRequest', () => { }); }); }); + + describe('with text filters', () => { + it('extracts full text filter and returns StreamEntitiesRequest', () => { + const textFilter = new EntityTextFilter('search term'); + + const filters: DefaultEntityFilters = { + text: textFilter, + }; + + const result = toStreamRequest(filters); + + expect(result).toEqual({ + fullTextFilter: { + term: 'search term', + fields: [ + 'metadata.name', + 'metadata.title', + 'spec.profile.displayName', + ], + }, + }); + }); + }); + + describe('with order filters', () => { + it('extracts order fields and returns StreamEntitiesRequest', () => { + const orderFilter = new EntityOrderFilter([['metadata.name', 'asc']]); + + const filters: DefaultEntityFilters = { + order: orderFilter, + }; + + const result = toStreamRequest(filters); + + expect(result).toEqual({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + }); + }); + + it('handles multiple order fields', () => { + const orderFilter = new EntityOrderFilter([ + ['metadata.name', 'asc'], + ['spec.type', 'desc'], + ]); + + const filters: DefaultEntityFilters = { + order: orderFilter, + }; + + const result = toStreamRequest(filters); + + expect(result).toEqual({ + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, + ], + }); + }); + }); + + describe('with combined filters', () => { + it('combines backend, text, and order filters', () => { + const mockBackendFilter = { + getCatalogFilters: () => ({ + kind: ['Component'], + }), + }; + + const textFilter = new EntityTextFilter('search term'); + const orderFilter = new EntityOrderFilter([['metadata.name', 'asc']]); + + const filters: DefaultEntityFilters = { + kind: mockBackendFilter as any, + text: textFilter, + order: orderFilter, + }; + + const result = toStreamRequest(filters); + + expect(result).toEqual({ + filter: { + kind: ['Component'], + }, + fullTextFilter: { + term: 'search term', + fields: [ + 'metadata.name', + 'metadata.title', + 'spec.profile.displayName', + ], + }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + }); + }); + }); }); diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.ts similarity index 60% rename from plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts rename to plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.ts index fae6c9ef7d..9d0866f1c9 100644 --- a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts +++ b/plugins/catalog/src/components/CatalogExportButton/file-download/toStreamRequest.ts @@ -13,13 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DefaultEntityFilters } from '@backstage/plugin-catalog-react'; -import type { StreamEntitiesRequest } from '@backstage/catalog-client'; +import { + DefaultEntityFilters, + EntityTextFilter, + EntityOrderFilter, +} from '@backstage/plugin-catalog-react'; +import type { + StreamEntitiesRequest, + EntityOrderQuery, +} from '@backstage/catalog-client'; function isBackendFilter(f: any): f is { getCatalogFilters: () => any } { return typeof f?.getCatalogFilters === 'function'; } +function isEntityTextFilter(f: any): f is EntityTextFilter { + return !!(f as EntityTextFilter).getFullTextFilters; +} + +function isEntityOrderFilter(f: any): f is EntityOrderFilter { + return !!(f as EntityOrderFilter).getOrderFilters; +} + function getBackendFilterObject( backendFilter: Record, ): Record { @@ -47,13 +62,13 @@ function getBackendFilterObject( * Converts entity filters to a StreamEntitiesRequest that can be used * with the catalogApi.streamEntities method. * - * This extracts all enabled backend filters and converts them to the - * appropriate format for streaming. + * This extracts all enabled backend filters, full text filters, and order filters + * and converts them to the appropriate format for streaming. * * @param filters - The entity filters from useEntityList - * @returns A StreamEntitiesRequest object, or undefined if no backend filters are enabled + * @returns A StreamEntitiesRequest object, or undefined if no filters are enabled */ -export const filtersToStreamRequest = ( +export const toStreamRequest = ( filters: DefaultEntityFilters, ): StreamEntitiesRequest | undefined => { const backendFilters = Object.values(filters) @@ -68,8 +83,29 @@ export const filtersToStreamRequest = ( return { ...acc, ...getBackendFilterObject(f) }; }, {} as Record); - // Return undefined if no filters, which means stream all entities - return Object.keys(backendFilters).length > 0 - ? { filter: backendFilters } + const fullTextFilter = Object.values(filters) + .find(isEntityTextFilter) + ?.getFullTextFilters(); + + const orderFieldsFilter = Object.values(filters).find(isEntityOrderFilter); + const orderFields = orderFieldsFilter + ? (orderFieldsFilter.getOrderFilters() as EntityOrderQuery) : undefined; + + const request: StreamEntitiesRequest = {}; + + if (Object.keys(backendFilters).length > 0) { + request.filter = backendFilters; + } + + if (fullTextFilter) { + request.fullTextFilter = fullTextFilter; + } + + if (orderFields) { + request.orderFields = orderFields; + } + + // Return undefined if no filters, which means stream all entities + return Object.keys(request).length > 0 ? request : undefined; }; diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts index cc74c20b22..def22e5642 100644 --- a/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts +++ b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts @@ -27,7 +27,7 @@ import type { CatalogApi, StreamEntitiesRequest, } from '@backstage/catalog-client'; -import { filtersToStreamRequest } from './filtersToStreamRequest'; +import { toStreamRequest } from './toStreamRequest'; /** * A custom exporter function that returns an async generator for streaming exports. @@ -145,7 +145,7 @@ export const useStreamingExport = (): { streamRequest, onSuccess, onError, - exporter: exporterFn, + exporter, }: StreamingExportOptions) => { setLoading(true); setError(null); @@ -153,11 +153,10 @@ export const useStreamingExport = (): { try { // If caller didn't provide a streamRequest, derive it from the // current EntityList filters so exports reflect the user's view. - const resolvedStreamRequest = - streamRequest ?? filtersToStreamRequest(filters); + const resolvedStreamRequest = streamRequest ?? toStreamRequest(filters); - if (exporterFn) { - const { generator, contentType } = exporterFn({ + if (exporter) { + const { generator, contentType } = exporter({ apis, columns, streamRequest: resolvedStreamRequest,