feature(catalog export): process copilot feedback

Signed-off-by: the-serious-programmer <19777147+the-serious-programmer@users.noreply.github.com>
This commit is contained in:
the-serious-programmer
2026-04-20 21:29:26 +02:00
parent 7348cc16fb
commit 3ec5c61b0e
5 changed files with 165 additions and 29 deletions
@@ -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.
@@ -15,4 +15,4 @@
*/
export * from './useStreamingExport';
export * from './serializeEntities';
export * from './filtersToStreamRequest';
export * from './toStreamRequest';
@@ -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' }],
});
});
});
});
@@ -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<string, any>,
): Record<string, string | string[]> {
@@ -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<string, string | string[]>);
// 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;
};
@@ -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,