diff --git a/.changeset/eager-birds-fly.md b/.changeset/eager-birds-fly.md
new file mode 100644
index 0000000000..3c5082b73b
--- /dev/null
+++ b/.changeset/eager-birds-fly.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog': patch
+---
+
+Added `CatalogExportButton`, which adds CSV and JSON export support to the `CatalogIndexPage`.
diff --git a/app-config.yaml b/app-config.yaml
index 86391154f3..13cc5f2f4a 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -71,6 +71,10 @@ app:
- nav-item:app-visualizer
# Pages
+ - page:catalog:
+ config:
+ exportSettings:
+ enabled: true
- page:catalog/entity:
config:
showNavItemIcons: true
diff --git a/docs/features/software-catalog/catalog-customization--old.md b/docs/features/software-catalog/catalog-customization--old.md
index c4ce27dc9e..42595efd7f 100644
--- a/docs/features/software-catalog/catalog-customization--old.md
+++ b/docs/features/software-catalog/catalog-customization--old.md
@@ -20,6 +20,198 @@ Initial support for pagination of the `CatalogIndexPage` was added in v1.21.0 of
} />
```
+## Export
+
+To enable the catalog export you need to pass in the `exportSettings` prop with `enabled: true`:
+
+```tsx title="packages/app/src/App.tsx"
+}
+/>
+```
+
+This will enable a button, which by default contains options to export data from the catalog table in CSV and JSON format. When exporting, a dialog opens that lets the user choose the export format and select which columns to include.
+
+### Customizing export
+
+You can customize the export behavior by configuring the `exportSettings` prop with various options via the `CatalogExportSettings` interface:
+
+```tsx
+export interface CatalogExportSettings {
+ enabled?: boolean;
+ /**
+ * Array of columns to include in the export.
+ *
+ * Each column requires an `entityFilterKey` (dot-separated path into the entity object that is returned by the catalog api) and an optional `title` for display.
+ * When `title` is omitted, `entityFilterKey` is used as the display title.
+ *
+ * Default columns are: name, type, owner and description.
+ **/
+ columns?: CatalogExportSettingsColumn;
+ /**
+ * Map of custom export format handlers.
+ *
+ * Each map entry provides an exporter function and an optional display label.
+ * Custom formats appear in the export dialog alongside built-in CSV and JSON options.
+ **/
+ exporters?: Record;
+ /** Callback function invoked after successful export completion. Useful for displaying notifications or triggering post-export actions. */
+ onSuccess?: () => void;
+ /** Callback function invoked if export fails. Receives an object containing the Error for error handling and user notification. */
+ onError?: (options: { error: Error }) => void;
+ /** When true, hides the built-in CSV and JSON export options. Useful when only custom exporters should be available. */
+ disableBuiltinExporters?: boolean;
+}
+```
+
+#### Custom export columns
+
+By default, the export includes name, type, owner and description columns.
+When the export dialog opens, all configured columns are shown as checkboxes and pre-selected.
+The user can deselect any columns they want to exclude before confirming the export.
+You can customize the available columns:
+
+```tsx title="packages/app/src/App.tsx"
+import { CatalogIndexPage } from '@backstage/plugin-catalog';
+
+const customColumns = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'metadata.namespace', title: 'Namespace' },
+ { entityFilterKey: 'spec.owner', title: 'Owner' },
+];
+
+;
+```
+
+#### Custom export formats
+
+You can add custom export format types beyond CSV and JSON by providing custom exporter functions.
+Custom exporters use **async generators** to enable streaming downloads.
+The data is written to disk as it's generated, without buffering the entire export in memory in supported browsers.
+
+```tsx title="packages/app/src/App.tsx"
+import {
+ CatalogIndexPage,
+ CatalogExporter,
+ CatalogExporterConfig,
+} from '@backstage/plugin-catalog';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+
+// Custom exporter using async generator for streaming
+const xmlExporter: CatalogExporter = ({ apis, columns, streamRequest }) => {
+ const catalogApi = apis.get(catalogApiRef);
+
+ // Return an async generator that yields XML chunks
+ async function* generateXml() {
+ yield '\n\n';
+
+ for await (const page of catalogApi.streamEntities(streamRequest)) {
+ for (const entity of page) {
+ // Serialize each entity to XML and yield immediately
+ yield serializeEntityToXml(entity, columns);
+ }
+ }
+
+ yield '';
+ }
+
+ return {
+ generator: generateXml(),
+ contentType: 'application/xml',
+ };
+};
+
+const yamlExporter: CatalogExporter = ({ apis, columns, streamRequest }) => {
+ const catalogApi = apis.get(catalogApiRef);
+
+ async function* generateYaml() {
+ for await (const page of catalogApi.streamEntities(streamRequest)) {
+ for (const entity of page) {
+ yield serializeEntityToYaml(entity, columns);
+ yield '---\n'; // YAML document separator
+ }
+ }
+ }
+
+ return {
+ generator: generateYaml(),
+ contentType: 'application/x-yaml',
+ };
+};
+
+const exporters: Record = {
+ xml: { exporter: xmlExporter, label: 'XML' },
+ yaml: { exporter: yamlExporter, label: 'YAML' },
+};
+
+;
+```
+
+When custom export formats are provided, they will appear in the export dialog alongside the built-in CSV and JSON options.
+
+#### Success/Error callbacks
+
+You can also provide callbacks to handle successful or failed exports:
+
+```tsx title="packages/app/src/App.tsx"
+ {
+ // Handle successful export
+ notificationApi.success({ message: 'Export completed!' });
+ },
+ onError: ({ error }) => {
+ // Handle export error
+ notificationApi.error({
+ message: `Export failed: ${error.message}`,
+ });
+ },
+ }}
+/>
+```
+
+#### Combined example
+
+Here's an example combining all customization options:
+
+```tsx title="packages/app/src/App.tsx"
+ {
+ notificationApi.success({ message: 'Export completed!' });
+ },
+ onError: ({ error }) => {
+ notificationApi.error({
+ message: `Export failed: ${error.message}`,
+ });
+ },
+ }}
+/>
+```
+
## Initially Selected Filter
By default, the initially selected filter defaults to Owned. If you are still building up your catalog this may show an empty list to start. If you would prefer this to show All as the default, here's how you can make that change:
diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md
index 33789c9b9e..18e0c98f60 100644
--- a/docs/features/software-catalog/catalog-customization.md
+++ b/docs/features/software-catalog/catalog-customization.md
@@ -37,6 +37,103 @@ app:
limit: 20
```
+### Configuring Catalog Export
+
+The catalog export feature is available in the new frontend system and can be enabled via the `app-config.yaml`.
+This will enable a button, which by default contains options to export data from the catalog table in CSV and JSON format.
+When exporting, a dialog opens that lets the user choose the export format and select which columns to include.
+
+#### Basic Configuration
+
+To enable catalog export, add the following configuration:
+
+```yaml title="app-config.yaml"
+app:
+ extensions:
+ - page:catalog:
+ config:
+ exportSettings:
+ enabled: true
+ # Optional: hide the built-in CSV and JSON formats, showing only your own supplied exporters
+ disableBuiltinExporters: false
+```
+
+This will display an "Export selection" button on the catalog index page that allows users to export the currently filtered catalog entities in CSV or JSON format.
+
+#### Advanced Configuration
+
+For advanced export customization like custom export formats, create a frontend module that provides a catalog export extension:
+
+```tsx title="src/catalogExportExtension.tsx"
+import { createFrontendModule } from '@backstage/frontend-plugin-api';
+import { CatalogExportConfigBlueprint } from '@backstage/plugin-catalog/alpha';
+import type { CatalogExporter } from '@backstage/plugin-catalog';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+
+// Define custom export formats using streaming async generators
+const yamlExporter: CatalogExporter = ({ apis, columns, streamRequest }) => {
+ const catalogApi = apis.get(catalogApiRef);
+
+ // Return an async generator that yields YAML chunks
+ async function* generateYaml() {
+ for await (const page of catalogApi.streamEntities(streamRequest)) {
+ for (const entity of page) {
+ // Serialize each entity to YAML and yield immediately
+ yield serializeEntityToYaml(entity, columns);
+ yield '---\n'; // YAML document separator
+ }
+ }
+ }
+
+ return {
+ generator: generateYaml(),
+ contentType: 'application/x-yaml',
+ };
+};
+
+// Create the extension using the blueprint
+const catalogExportExtension = CatalogExportConfigBlueprint.make({
+ params: {
+ exporters: {
+ yaml: { fn: yamlExporter, label: 'YAML' },
+ },
+ columns: [{ entityFilterKey: 'metadata.name', title: 'Name' }],
+ onSuccess: () => {
+ console.log('Export successful!');
+ },
+ onError: ({ error }) => {
+ console.error('Export failed:', error);
+ },
+ },
+});
+
+// Create the module that provides this extension
+export default createFrontendModule({
+ pluginId: 'catalog',
+ extensions: [catalogExportExtension],
+});
+```
+
+Then register this module in your app features:
+
+```tsx title="packages/app-next/src/App.tsx"
+import catalogExportExtension from './catalogExportExtension';
+
+const app = createApp({
+ features: [
+ // ... other features
+ catalogExportExtension,
+ ],
+});
+```
+
+The `CatalogExportConfigBlueprint` supports the following properties:
+
+- **`exporters`** - Record of custom export format configurations (e.g., XML, YAML), each with a `fn` exporter function and optional `label`
+- **`columns`** - Custom columns to include in the export. Each column specifies an entity field path (`entityFilterKey`) and an optional display `title`. If not provided, defaults to Name, Type, Owner, and Description
+- **`onSuccess`** - Callback function invoked on successful export
+- **`onError`** - Callback function invoked if export fails, receives `{ error: Error }`
+
### Catalog filters
The catalog index page includes a set of default filters (kind, type, owner, lifecycle, tag, namespace, processing status). These filters can be configured through extensions. For example, to set the initial kind filter:
@@ -181,12 +278,12 @@ export const CustomCatalogPage = () => {
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
return (
-
-
-
- All your software catalog entities
-
-
+
+
+
+
+ All your software catalog entities
+
@@ -202,9 +299,9 @@ export const CustomCatalogPage = () => {
-
-
-
+
+
+
);
};
```
@@ -222,8 +319,6 @@ 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/packages/app-legacy/src/App.tsx b/packages/app-legacy/src/App.tsx
index 45f91ce8a9..19ce13cfd6 100644
--- a/packages/app-legacy/src/App.tsx
+++ b/packages/app-legacy/src/App.tsx
@@ -121,7 +121,12 @@ const routes = (
}
+ element={
+
+ }
/>
;
+ inputs: {};
+ config: {};
+ configInput: {};
+ dataRefs: never;
+}>;
+
+// @public
+export type CatalogExporter = (options: {
+ apis: ApiHolder;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+}) => {
+ generator: AsyncGenerator;
+ contentType: string;
+};
+
+// @public
+export interface CatalogExporterConfig {
+ exporter: CatalogExporter;
+ label?: string;
+}
+
+// @public
+export interface CatalogExportSettings {
+ columns?: CatalogExportSettingsColumn[];
+ disableBuiltinExporters?: boolean;
+ enabled?: boolean;
+ exporters?: Record;
+ onError?: (options: { error: Error }) => void;
+ onSuccess?: () => void;
+}
+
+// @public
+export interface CatalogExportSettingsColumn {
+ entityFilterKey: string;
+ title?: string;
+}
+
// @public (undocumented)
export function CatalogIndexPage(props: CatalogIndexPageProps): JSX_3.Element;
@@ -53,6 +113,8 @@ export interface CatalogIndexPageProps {
// (undocumented)
emptyContent?: ReactNode;
// (undocumented)
+ exportSettings?: CatalogExportSettings;
+ // (undocumented)
filters?: ReactNode;
// (undocumented)
initialKind?: string;
@@ -131,8 +193,8 @@ export const catalogTranslationRef: TranslationRef<
readonly 'aboutCard.targetsField.label': 'Targets';
readonly 'searchResultItem.type': 'Type';
readonly 'searchResultItem.kind': 'Kind';
- readonly 'searchResultItem.owner': 'Owner';
readonly 'searchResultItem.lifecycle': 'Lifecycle';
+ readonly 'searchResultItem.owner': 'Owner';
readonly 'catalogTable.allFilters': 'All';
readonly 'catalogTable.warningPanelTitle': 'Could not fetch catalog entities.';
readonly 'catalogTable.viewActionTitle': 'View';
@@ -152,20 +214,29 @@ export const catalogTranslationRef: TranslationRef<
readonly 'entityContextMenu.unregisterMenuTitle': 'Unregister entity';
readonly 'entityContextMenu.moreButtonAriaLabel': 'more';
readonly 'entityLabelsCard.title': 'Labels';
- readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more';
readonly 'entityLabelsCard.columnKeyLabel': 'Label';
readonly 'entityLabelsCard.columnValueLabel': 'Value';
readonly 'entityLabelsCard.emptyDescription': 'No labels defined for this entity. You can add labels to your entity YAML as shown in the highlighted example below:';
- readonly 'entityLabels.ownerLabel': 'Owner';
+ readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more';
readonly 'entityLabels.warningPanelTitle': 'Entity not found';
+ readonly 'entityLabels.ownerLabel': 'Owner';
readonly 'entityLabels.lifecycleLabel': 'Lifecycle';
readonly 'entityLinksCard.title': 'Links';
- readonly 'entityLinksCard.readMoreButtonTitle': 'Read more';
readonly 'entityLinksCard.emptyDescription': 'No links defined for this entity. You can add links to your entity YAML as shown in the highlighted example below:';
+ readonly 'entityLinksCard.readMoreButtonTitle': 'Read more';
readonly 'entityNotFound.title': 'Entity was not found';
readonly 'entityNotFound.description': 'Want to help us build this? Check out our Getting Started documentation.';
readonly 'entityNotFound.docButtonTitle': 'DOCS';
readonly 'entityTabs.tabsAriaLabel': 'Tabs';
+ readonly 'catalogExportButton.errorMessage': 'Failed to export catalog: {{errorMessage}}';
+ readonly 'catalogExportButton.cancelButtonTitle': 'Cancel';
+ readonly 'catalogExportButton.dialogTitle': 'Export catalog selection';
+ readonly 'catalogExportButton.triggerButtonTitle': 'Export selection';
+ readonly 'catalogExportButton.formatLabel': 'Format';
+ readonly 'catalogExportButton.columnsLabel': 'Columns';
+ readonly 'catalogExportButton.confirmButtonTitle': 'Confirm';
+ readonly 'catalogExportButton.exportingButtonTitle': 'Exporting…';
+ readonly 'catalogExportButton.successMessage': 'Catalog exported successfully';
readonly entityProcessingErrorsDescription: 'The error below originates from';
readonly entityRelationWarningDescription: "This entity has relations to other entities, which can't be found in the catalog.\n Entities not found are: ";
readonly 'hasComponentsCard.title': 'Has components';
@@ -1061,6 +1132,12 @@ const _default: OverridableFrontendPlugin<
limit?: number | undefined;
offset?: number | undefined;
};
+ exportSettings:
+ | {
+ enabled?: boolean | undefined;
+ disableBuiltinExporters?: boolean | undefined;
+ }
+ | undefined;
path: string | undefined;
title: string | undefined;
};
@@ -1073,6 +1150,12 @@ const _default: OverridableFrontendPlugin<
offset?: number | undefined;
}
| undefined;
+ exportSettings?:
+ | {
+ enabled?: boolean | undefined;
+ disableBuiltinExporters?: boolean | undefined;
+ }
+ | undefined;
path?: string | undefined;
title?: string | undefined;
};
@@ -1139,6 +1222,25 @@ const _default: OverridableFrontendPlugin<
internal: false;
}
>;
+ exportConfig: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ {
+ exporters?: CatalogExportSettings['exporters'];
+ columns?: CatalogExportSettings['columns'];
+ onSuccess?: CatalogExportSettings['onSuccess'];
+ onError?: CatalogExportSettings['onError'];
+ },
+ 'catalog.export-customization',
+ {
+ optional: true;
+ }
+ >,
+ {
+ singleton: false;
+ optional: false;
+ internal: false;
+ }
+ >;
};
kind: 'page';
name: undefined;
diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md
index e89ceecf15..b1c9b4724a 100644
--- a/plugins/catalog/report.api.md
+++ b/plugins/catalog/report.api.md
@@ -33,6 +33,7 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { SearchResultListItemExtensionProps } from '@backstage/plugin-search-react';
import { StarredEntitiesApi } from '@backstage/plugin-catalog-react';
import { StorageApi } from '@backstage/core-plugin-api';
+import type { StreamEntitiesRequest } from '@backstage/catalog-client';
import { StyleRules } from '@material-ui/core/styles/withStyles';
import { SystemEntity } from '@backstage/catalog-model';
import { TableColumn } from '@backstage/core-components';
@@ -79,6 +80,38 @@ export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
// @public (undocumented)
export const CatalogEntityPage: () => JSX.Element;
+// @public
+export type CatalogExporter = (options: {
+ apis: ApiHolder;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+}) => {
+ generator: AsyncGenerator;
+ contentType: string;
+};
+
+// @public
+export interface CatalogExporterConfig {
+ exporter: CatalogExporter;
+ label?: string;
+}
+
+// @public
+export interface CatalogExportSettings {
+ columns?: CatalogExportSettingsColumn[];
+ disableBuiltinExporters?: boolean;
+ enabled?: boolean;
+ exporters?: Record;
+ onError?: (options: { error: Error }) => void;
+ onSuccess?: () => void;
+}
+
+// @public
+export interface CatalogExportSettingsColumn {
+ entityFilterKey: string;
+ title?: string;
+}
+
// @public (undocumented)
export const CatalogIndexPage: (props: DefaultCatalogPageProps) => JSX.Element;
@@ -260,8 +293,8 @@ export const catalogTranslationRef: TranslationRef<
readonly 'aboutCard.targetsField.label': 'Targets';
readonly 'searchResultItem.type': 'Type';
readonly 'searchResultItem.kind': 'Kind';
- readonly 'searchResultItem.owner': 'Owner';
readonly 'searchResultItem.lifecycle': 'Lifecycle';
+ readonly 'searchResultItem.owner': 'Owner';
readonly 'catalogTable.allFilters': 'All';
readonly 'catalogTable.warningPanelTitle': 'Could not fetch catalog entities.';
readonly 'catalogTable.viewActionTitle': 'View';
@@ -281,20 +314,29 @@ export const catalogTranslationRef: TranslationRef<
readonly 'entityContextMenu.unregisterMenuTitle': 'Unregister entity';
readonly 'entityContextMenu.moreButtonAriaLabel': 'more';
readonly 'entityLabelsCard.title': 'Labels';
- readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more';
readonly 'entityLabelsCard.columnKeyLabel': 'Label';
readonly 'entityLabelsCard.columnValueLabel': 'Value';
readonly 'entityLabelsCard.emptyDescription': 'No labels defined for this entity. You can add labels to your entity YAML as shown in the highlighted example below:';
- readonly 'entityLabels.ownerLabel': 'Owner';
+ readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more';
readonly 'entityLabels.warningPanelTitle': 'Entity not found';
+ readonly 'entityLabels.ownerLabel': 'Owner';
readonly 'entityLabels.lifecycleLabel': 'Lifecycle';
readonly 'entityLinksCard.title': 'Links';
- readonly 'entityLinksCard.readMoreButtonTitle': 'Read more';
readonly 'entityLinksCard.emptyDescription': 'No links defined for this entity. You can add links to your entity YAML as shown in the highlighted example below:';
+ readonly 'entityLinksCard.readMoreButtonTitle': 'Read more';
readonly 'entityNotFound.title': 'Entity was not found';
readonly 'entityNotFound.description': 'Want to help us build this? Check out our Getting Started documentation.';
readonly 'entityNotFound.docButtonTitle': 'DOCS';
readonly 'entityTabs.tabsAriaLabel': 'Tabs';
+ readonly 'catalogExportButton.errorMessage': 'Failed to export catalog: {{errorMessage}}';
+ readonly 'catalogExportButton.cancelButtonTitle': 'Cancel';
+ readonly 'catalogExportButton.dialogTitle': 'Export catalog selection';
+ readonly 'catalogExportButton.triggerButtonTitle': 'Export selection';
+ readonly 'catalogExportButton.formatLabel': 'Format';
+ readonly 'catalogExportButton.columnsLabel': 'Columns';
+ readonly 'catalogExportButton.confirmButtonTitle': 'Confirm';
+ readonly 'catalogExportButton.exportingButtonTitle': 'Exporting…';
+ readonly 'catalogExportButton.successMessage': 'Catalog exported successfully';
readonly entityProcessingErrorsDescription: 'The error below originates from';
readonly entityRelationWarningDescription: "This entity has relations to other entities, which can't be found in the catalog.\n Entities not found are: ";
readonly 'hasComponentsCard.title': 'Has components';
@@ -328,6 +370,8 @@ export interface DefaultCatalogPageProps {
// (undocumented)
emptyContent?: ReactNode;
// (undocumented)
+ exportSettings?: CatalogExportSettings;
+ // (undocumented)
filters?: ReactNode;
// (undocumented)
initialKind?: string;
diff --git a/plugins/catalog/src/alpha/index.ts b/plugins/catalog/src/alpha/index.ts
index a99f7c4ad4..3122a53561 100644
--- a/plugins/catalog/src/alpha/index.ts
+++ b/plugins/catalog/src/alpha/index.ts
@@ -16,6 +16,13 @@
export { default } from './plugin';
+export { CatalogExportConfigBlueprint } from './pages';
+export type {
+ CatalogExportSettings,
+ CatalogExporterConfig,
+ CatalogExporter,
+ CatalogExportSettingsColumn,
+} from '../components/CatalogExportButton';
export { NfsDefaultCatalogPage as CatalogIndexPage } from '../components/CatalogPage';
export type { DefaultCatalogPageProps as CatalogIndexPageProps } from '../components/CatalogPage';
export type {
diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx
index ac35931892..4d270648e2 100644
--- a/plugins/catalog/src/alpha/pages.tsx
+++ b/plugins/catalog/src/alpha/pages.tsx
@@ -18,6 +18,8 @@ import { convertLegacyRouteRef } from '@backstage/core-compat-api';
import {
coreExtensionData,
createExtensionInput,
+ createExtensionDataRef,
+ createExtensionBlueprint,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { z } from 'zod/v4';
@@ -36,10 +38,39 @@ import CategoryIcon from '@material-ui/icons/Category';
import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import { buildFilterFn } from './filter/FilterWrapper';
+import type { CatalogExportSettings } from '../components/CatalogExportButton';
+
+const catalogExportConfigDataRef = createExtensionDataRef<{
+ exporters?: CatalogExportSettings['exporters'];
+ columns?: CatalogExportSettings['columns'];
+ onSuccess?: CatalogExportSettings['onSuccess'];
+ onError?: CatalogExportSettings['onError'];
+}>().with({
+ id: 'catalog.export-customization',
+});
+
+/**
+ * Blueprint for creating catalog export configuration extensions.
+ * @public
+ */
+export const CatalogExportConfigBlueprint = createExtensionBlueprint({
+ kind: 'catalog-export-config',
+ attachTo: { id: 'page:catalog', input: 'exportConfig' },
+ output: [catalogExportConfigDataRef],
+ factory(params: {
+ exporters?: CatalogExportSettings['exporters'];
+ columns?: CatalogExportSettings['columns'];
+ onSuccess?: CatalogExportSettings['onSuccess'];
+ onError?: CatalogExportSettings['onError'];
+ }) {
+ return [catalogExportConfigDataRef(params)];
+ },
+});
export const catalogPage = PageBlueprint.makeWithOverrides({
inputs: {
filters: createExtensionInput([coreExtensionData.reactElement]),
+ exportConfig: createExtensionInput([catalogExportConfigDataRef.optional()]),
},
configSchema: {
pagination: z
@@ -52,6 +83,17 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
}),
])
.default(true),
+ exportSettings: z
+ .object({
+ /** When true, displays the export button in the catalog interface. */
+ enabled: z.boolean().optional(),
+ /**
+ * When true, hides the built-in CSV and JSON export options.
+ * Useful when only custom exporters (provided via extensions) should be available.
+ */
+ disableBuiltinExporters: z.boolean().optional(),
+ })
+ .optional(),
},
factory(originalFactory, { inputs, config }) {
return originalFactory({
@@ -66,10 +108,40 @@ export const catalogPage = PageBlueprint.makeWithOverrides({
const filters = inputs.filters.map(filter =>
filter.get(coreExtensionData.reactElement),
);
+
+ // Merge export customizers from all attached extensions
+ const mergedExportSettings: CatalogExportSettings = {
+ ...config.exportSettings,
+ };
+
+ for (const exportConfigInput of inputs.exportConfig) {
+ const data = exportConfigInput.get(catalogExportConfigDataRef);
+ if (data) {
+ if (data.exporters) {
+ mergedExportSettings.exporters = {
+ ...mergedExportSettings.exporters,
+ ...data.exporters,
+ };
+ }
+ if (data.columns && !mergedExportSettings.columns) {
+ mergedExportSettings.columns = data.columns;
+ }
+ if (data.onSuccess && !mergedExportSettings.onSuccess) {
+ mergedExportSettings.onSuccess = data.onSuccess;
+ }
+ if (data.onError && !mergedExportSettings.onError) {
+ mergedExportSettings.onError = data.onError;
+ }
+ }
+ }
+
return (
{filters}>}
pagination={config.pagination}
+ exportSettings={
+ mergedExportSettings.enabled ? mergedExportSettings : undefined
+ }
/>
);
},
diff --git a/plugins/catalog/src/alpha/translation.ts b/plugins/catalog/src/alpha/translation.ts
index ef35cece84..c7a5c184c0 100644
--- a/plugins/catalog/src/alpha/translation.ts
+++ b/plugins/catalog/src/alpha/translation.ts
@@ -148,6 +148,17 @@ export const catalogTranslationRef = createTranslationRef({
'This entity is not referenced by any location and is therefore not receiving updates.',
actionButtonTitle: 'Delete entity',
},
+ catalogExportButton: {
+ triggerButtonTitle: 'Export selection',
+ dialogTitle: 'Export catalog selection',
+ formatLabel: 'Format',
+ columnsLabel: 'Columns',
+ cancelButtonTitle: 'Cancel',
+ confirmButtonTitle: 'Confirm',
+ exportingButtonTitle: 'Exporting…',
+ successMessage: 'Catalog exported successfully',
+ errorMessage: 'Failed to export catalog: {{errorMessage}}',
+ },
entityProcessingErrorsDescription: 'The error below originates from',
entityRelationWarningDescription:
"This entity has relations to other entities, which can't be found in the catalog.\n Entities not found are: ",
diff --git a/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.test.tsx b/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.test.tsx
new file mode 100644
index 0000000000..dcac751214
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.test.tsx
@@ -0,0 +1,444 @@
+/*
+ * 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 { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CatalogExportButton } from './CatalogExportButton';
+import { useStreamingExport } from './file-download';
+import {
+ catalogApiRef,
+ EntityListProvider,
+} from '@backstage/plugin-catalog-react';
+import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { toastApiRef } from '@backstage/frontend-plugin-api';
+
+const mockToastApi = {
+ post: jest.fn(),
+};
+
+jest.mock('./file-download/useStreamingExport', () => ({
+ useStreamingExport: jest.fn(),
+}));
+const useStreamingExportMock = useStreamingExport as jest.Mock;
+const mockExportStream = jest.fn();
+
+const renderComponent = (
+ settings?: Parameters[0]['settings'],
+) =>
+ renderInTestApp(
+
+
+
+
+ ,
+ );
+
+describe('CatalogExportButton', () => {
+ beforeEach(() => {
+ useStreamingExportMock.mockReturnValue({
+ exportStream: mockExportStream,
+ loading: false,
+ error: null,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the export button', async () => {
+ await renderComponent();
+ expect(
+ screen.getByRole('button', { name: /Export selection/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('opens and closes the dialog', async () => {
+ await renderComponent();
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Export catalog selection')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('handles successful export', async () => {
+ mockExportStream.mockResolvedValueOnce(undefined);
+
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledTimes(1);
+ expect(mockToastApi.post).toHaveBeenCalledWith({
+ title: 'Catalog exported successfully',
+ status: 'success',
+ });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('calls onSuccess callback if provided', async () => {
+ mockExportStream.mockResolvedValueOnce(undefined);
+ const onSuccess = jest.fn();
+
+ await renderComponent({ onSuccess });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+ // Alert should not be shown when callback is provided
+ expect(mockToastApi.post).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Catalog exported successfully',
+ }),
+ );
+ });
+ });
+
+ it('handles failed export', async () => {
+ const testError = new Error('Network error');
+
+ useStreamingExportMock.mockReturnValue({
+ exportStream: mockExportStream,
+ loading: false,
+ error: testError,
+ });
+
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledTimes(1);
+ expect(mockToastApi.post).toHaveBeenCalledWith({
+ title: `Failed to export catalog: ${testError.message}`,
+ status: 'danger',
+ });
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ it('calls onError callback if provided on failure', async () => {
+ const testError = new Error('Network error');
+ const onError = jest.fn();
+
+ useStreamingExportMock.mockReturnValue({
+ exportStream: mockExportStream,
+ loading: false,
+ error: testError,
+ });
+
+ await renderComponent({ onError });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalledWith({ error: testError });
+ // Alert should not be shown when callback is provided
+ expect(mockToastApi.post).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining('Failed to export'),
+ }),
+ );
+ });
+ });
+
+ it('allows changing the export format and calls exportStream with it', async () => {
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ const formatSelect = screen.getByTestId('format-select');
+ await waitFor(() => {
+ expect(formatSelect).toHaveTextContent('CSV');
+ });
+
+ const selectButton = within(formatSelect).getByRole('button');
+ await userEvent.click(selectButton);
+
+ await userEvent.click(await screen.findByRole('option', { name: 'JSON' }));
+
+ await waitFor(() => {
+ expect(formatSelect).toHaveTextContent('JSON');
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledWith({
+ exportFormat: 'json',
+ filename: 'catalog-export.json',
+ columns: [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'spec.type', title: 'Type' },
+ { entityFilterKey: 'spec.owner', title: 'Owner' },
+ { entityFilterKey: 'metadata.description', title: 'Description' },
+ ],
+ streamRequest: undefined,
+ });
+ });
+ });
+
+ it('passes custom columns to exportStream if provided', async () => {
+ const customColumns = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'metadata.namespace', title: 'Namespace' },
+ ];
+
+ mockExportStream.mockClear();
+ useStreamingExportMock.mockReturnValue({
+ exportStream: mockExportStream,
+ loading: false,
+ error: null,
+ });
+
+ await renderComponent({ columns: customColumns });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ mockExportStream.mockResolvedValueOnce(undefined);
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ columns: customColumns,
+ }),
+ );
+ });
+ });
+
+ it('shows column checkboxes for default columns in the dialog', async () => {
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ expect(screen.getByRole('checkbox', { name: 'Name' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Type' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Owner' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Description' })).toBeChecked();
+ });
+
+ it('excludes deselected columns from the export', async () => {
+ mockExportStream.mockResolvedValueOnce(undefined);
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Type' }));
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Owner' }));
+
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ columns: [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'metadata.description', title: 'Description' },
+ ],
+ }),
+ );
+ });
+ });
+
+ it('disables the Confirm button when no columns are selected', async () => {
+ await renderComponent();
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Name' }));
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Type' }));
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Owner' }));
+ await userEvent.click(
+ screen.getByRole('checkbox', { name: 'Description' }),
+ );
+
+ expect(screen.getByRole('button', { name: /Confirm/i })).toBeDisabled();
+ });
+
+ it('resets column selection when dialog is reopened', async () => {
+ await renderComponent();
+
+ // Open dialog, deselect a column, close
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ await userEvent.click(screen.getByRole('checkbox', { name: 'Type' }));
+ expect(screen.getByRole('checkbox', { name: 'Type' })).not.toBeChecked();
+ await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
+ await waitFor(() => {
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ // Reopen, column should be checked again
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+ expect(screen.getByRole('checkbox', { name: 'Type' })).toBeChecked();
+ });
+
+ it('shows column checkboxes for custom columns in the dialog', async () => {
+ const customColumns = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'metadata.namespace', title: 'Namespace' },
+ ];
+
+ await renderComponent({ columns: customColumns });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ expect(screen.getByRole('checkbox', { name: 'Name' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Namespace' })).toBeChecked();
+ expect(
+ screen.queryByRole('checkbox', { name: 'Type' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('hides built-in export types when disableBuiltinExporters is true', async () => {
+ const exporters = {
+ xml: { exporter: jest.fn(), label: 'XML' },
+ };
+
+ await renderComponent({ disableBuiltinExporters: true, exporters });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ const formatSelect = screen.getByTestId('format-select');
+ const selectButton = within(formatSelect).getByRole('button');
+ await userEvent.click(selectButton);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole('option', { name: 'CSV' }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('option', { name: 'JSON' }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'XML' })).toBeInTheDocument();
+ });
+ });
+
+ it('shows custom export types in the dialog', async () => {
+ const exporters = {
+ xml: { exporter: jest.fn(), label: 'XML' },
+ yaml: { exporter: jest.fn(), label: 'YAML' },
+ };
+
+ await renderComponent({ exporters });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ const formatSelect = screen.getByTestId('format-select');
+ const selectButton = within(formatSelect).getByRole('button');
+ await userEvent.click(selectButton);
+
+ // Check that both built-in and custom export types are available
+ await waitFor(() => {
+ expect(screen.getByRole('option', { name: 'CSV' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'JSON' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'XML' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'YAML' })).toBeInTheDocument();
+ });
+ });
+
+ it('disables confirm button when disableBuiltinExporters is true and no exporters are provided', async () => {
+ await renderComponent({ disableBuiltinExporters: true });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ expect(screen.getByRole('button', { name: /Confirm/i })).toBeDisabled();
+ });
+
+ it('passes custom exporter fn to exportStream when custom type is selected', async () => {
+ const mockCustomExporterFn = jest.fn();
+ const exporters = {
+ xml: { exporter: mockCustomExporterFn },
+ };
+
+ mockExportStream.mockClear();
+ useStreamingExportMock.mockReturnValue({
+ exportStream: mockExportStream,
+ loading: false,
+ error: null,
+ });
+
+ await renderComponent({ exporters });
+
+ await userEvent.click(
+ screen.getByRole('button', { name: /Export selection/i }),
+ );
+
+ const formatSelect = screen.getByTestId('format-select');
+ const selectButton = within(formatSelect).getByRole('button');
+ await userEvent.click(selectButton);
+
+ await userEvent.click(screen.getByRole('option', { name: 'XML' }));
+
+ mockExportStream.mockResolvedValueOnce(undefined);
+ await userEvent.click(screen.getByRole('button', { name: /Confirm/i }));
+
+ await waitFor(() => {
+ expect(mockExportStream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ exportFormat: 'xml',
+ exporterFn: mockCustomExporterFn,
+ }),
+ );
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.tsx b/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.tsx
new file mode 100644
index 0000000000..ee1cb4972d
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/CatalogExportButton.tsx
@@ -0,0 +1,279 @@
+/*
+ * 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 { useCallback, useEffect, useState } from 'react';
+import { RiDownloadLine } from '@remixicon/react';
+import { useApiHolder } from '@backstage/core-plugin-api';
+import { useTranslationRef, toastApiRef } from '@backstage/frontend-plugin-api';
+import { catalogTranslationRef } from '../../alpha/translation';
+import {
+ Button,
+ Checkbox,
+ Dialog,
+ DialogBody,
+ DialogFooter,
+ DialogHeader,
+ Flex,
+ Select,
+ Text,
+} from '@backstage/ui';
+import { useStreamingExport } from './file-download';
+import type { CatalogExportSettingsColumn } from './file-download/serializeEntities';
+import { getColumnTitle } from './file-download/serializeEntities';
+import type { CatalogExporter } from './file-download/useStreamingExport';
+
+/**
+ * Custom exporter configuration for a catalog export format.
+ * @public
+ */
+export interface CatalogExporterConfig {
+ /** The exporter function that generates the export content. */
+ exporter: CatalogExporter;
+ /** Optional display label shown in the format selector. Defaults to the format key in uppercase. */
+ label?: string;
+}
+
+/**
+ * Settings for configuring the catalog export functionality.
+ *
+ * @public
+ */
+export interface CatalogExportSettings {
+ /**
+ * When true, displays the export button in the catalog interface.
+ * Defaults to false if not specified.
+ */
+ enabled?: boolean;
+
+ /**
+ * Custom columns to include in the export.
+ * Each column specifies an entity field path and a display title.
+ * If not specified, uses default columns: Name, Type, Owner, Description.
+ */
+ columns?: CatalogExportSettingsColumn[];
+
+ /**
+ * Map of custom export format handlers beyond the built-in CSV and JSON formats.
+ * Key is the format name (e.g., 'xml', 'yaml'), value is the exporter configuration.
+ * Custom formats will appear as options in the export dialog.
+ */
+ exporters?: Record;
+
+ /**
+ * When true, hides the built-in CSV and JSON export options from the dialog.
+ * Useful when only custom exporters should be available.
+ */
+ disableBuiltinExporters?: boolean;
+
+ /**
+ * Callback function invoked when the export completes successfully.
+ * Useful for showing notifications or performing post-export actions.
+ */
+ onSuccess?: () => void;
+
+ /**
+ * Callback function invoked when the export fails.
+ * Receives an object containing the error for custom error handling.
+ */
+ onError?: (options: { error: Error }) => void;
+}
+
+/**
+ * The available export formats for the catalog export.
+ * Currently supports CSV and JSON.
+ *
+ * @public
+ */
+export enum CatalogExportType {
+ CSV = 'csv',
+ JSON = 'json',
+}
+
+/**
+ * The available default export columns for the catalog export.
+ * These can be overridden by providing custom columns in the export button options.
+ *
+ * @private
+ */
+const DEFAULT_EXPORT_COLUMNS = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'spec.type', title: 'Type' },
+ { entityFilterKey: 'spec.owner', title: 'Owner' },
+ { entityFilterKey: 'metadata.description', title: 'Description' },
+];
+
+/**
+ * A button that opens a dialog to export the current catalog selection.
+ *
+ * @param settings - Optional export configuration settings including columns, custom exporters, and callbacks
+ * @public
+ */
+export const CatalogExportButton = ({
+ settings,
+}: {
+ settings?: CatalogExportSettings;
+}) => {
+ const { t } = useTranslationRef(catalogTranslationRef);
+ const { exportStream, loading, error } = useStreamingExport();
+ const [open, setOpen] = useState(false);
+ const exporters = settings?.exporters;
+ const disableBuiltinExporters = settings?.disableBuiltinExporters;
+ const onSuccess = settings?.onSuccess;
+ const onError = settings?.onError;
+ const apis = useApiHolder();
+ const toastApi = apis.get(toastApiRef)!;
+ const [isExporting, setIsExporting] = useState(false);
+
+ const effectiveColumns = settings?.columns ?? DEFAULT_EXPORT_COLUMNS;
+
+ const allExportOptions = [
+ ...(disableBuiltinExporters
+ ? []
+ : Object.values(CatalogExportType).map(format => ({
+ value: format,
+ label: format.toUpperCase(),
+ }))),
+ ...Object.entries(exporters ?? {}).map(([key, exporter]) => ({
+ value: key,
+ label: exporter.label ?? key.toUpperCase(),
+ })),
+ ];
+
+ const [exportFormat, setExportFormat] = useState(
+ allExportOptions[0]?.value ?? '',
+ );
+ const [selectedColumnTitles, setSelectedColumnTitles] = useState>(
+ () => new Set(effectiveColumns.map(c => getColumnTitle(c))),
+ );
+ const selectedColumns = effectiveColumns.filter(c =>
+ selectedColumnTitles.has(getColumnTitle(c)),
+ );
+
+ const handleOpenDialog = () => {
+ setSelectedColumnTitles(
+ new Set(effectiveColumns.map(c => getColumnTitle(c))),
+ );
+ setOpen(true);
+ };
+
+ const toggleColumn = (title: string) => {
+ setSelectedColumnTitles(prev => {
+ const next = new Set(prev);
+ if (next.has(title)) {
+ next.delete(title);
+ } else {
+ next.add(title);
+ }
+ return next;
+ });
+ };
+
+ useEffect(() => {
+ if (isExporting && !loading) {
+ if (error) {
+ if (onError) {
+ onError({ error });
+ } else {
+ toastApi.post({
+ title: t('catalogExportButton.errorMessage', {
+ errorMessage: error.message,
+ }),
+ status: 'danger',
+ });
+ }
+ } else {
+ if (onSuccess) {
+ onSuccess();
+ } else {
+ toastApi.post({
+ title: t('catalogExportButton.successMessage'),
+ status: 'success',
+ });
+ }
+ }
+ setOpen(false);
+ setIsExporting(false);
+ }
+ }, [isExporting, loading, error, toastApi, onSuccess, onError, t]);
+
+ const handleExport = useCallback(async () => {
+ setIsExporting(true);
+ const exporterFn = exporters?.[exportFormat];
+ await exportStream({
+ exportFormat,
+ filename: `catalog-export.${exportFormat}`,
+ columns: selectedColumns,
+ exporter: exporterFn?.exporter,
+ });
+ }, [exportFormat, exportStream, selectedColumns, exporters]);
+
+ return (
+ <>
+ }
+ onPress={handleOpenDialog}
+ >
+ {t('catalogExportButton.triggerButtonTitle')}
+
+
+
+ >
+ );
+};
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.test.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.test.ts
new file mode 100644
index 0000000000..8ab6e4d2b9
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.test.ts
@@ -0,0 +1,288 @@
+/*
+ * 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 { streamDownload, createStreamFromAsyncGenerator } from './downloadFile';
+
+describe('downloadBlob', () => {
+ describe('createStreamFromAsyncGenerator', () => {
+ it('creates a ReadableStream from an async generator', async () => {
+ async function* testGenerator(): AsyncGenerator {
+ yield 'Hello, ';
+ yield 'World!';
+ }
+
+ const stream = createStreamFromAsyncGenerator(testGenerator());
+ const reader = stream.getReader();
+
+ const chunks: Uint8Array[] = [];
+ let result = await reader.read();
+ while (!result.done) {
+ chunks.push(result.value);
+ result = await reader.read();
+ }
+
+ const decoder = new TextDecoder();
+ const text = chunks.map(chunk => decoder.decode(chunk)).join('');
+ expect(text).toBe('Hello, World!');
+ });
+
+ it('handles empty generator', async () => {
+ async function* emptyGenerator(): AsyncGenerator {
+ // yields nothing
+ }
+
+ const stream = createStreamFromAsyncGenerator(emptyGenerator());
+ const reader = stream.getReader();
+
+ const result = await reader.read();
+ expect(result.done).toBe(true);
+ });
+
+ it('handles single chunk', async () => {
+ async function* singleChunkGenerator(): AsyncGenerator<
+ string,
+ void,
+ unknown
+ > {
+ yield 'Single chunk';
+ }
+
+ const stream = createStreamFromAsyncGenerator(singleChunkGenerator());
+ const reader = stream.getReader();
+
+ const chunks: Uint8Array[] = [];
+ let result = await reader.read();
+ while (!result.done) {
+ chunks.push(result.value);
+ result = await reader.read();
+ }
+
+ const decoder = new TextDecoder();
+ const text = chunks.map(chunk => decoder.decode(chunk)).join('');
+ expect(text).toBe('Single chunk');
+ });
+
+ it('encodes unicode characters correctly', async () => {
+ async function* unicodeGenerator(): AsyncGenerator<
+ string,
+ void,
+ unknown
+ > {
+ yield '日本語';
+ yield ' 🎉 ';
+ yield 'émojis';
+ }
+
+ const stream = createStreamFromAsyncGenerator(unicodeGenerator());
+ const reader = stream.getReader();
+
+ const chunks: Uint8Array[] = [];
+ let result = await reader.read();
+ while (!result.done) {
+ chunks.push(result.value);
+ result = await reader.read();
+ }
+
+ const decoder = new TextDecoder();
+ const text = chunks.map(chunk => decoder.decode(chunk)).join('');
+ expect(text).toBe('日本語 🎉 émojis');
+ });
+ });
+
+ describe('streamDownload', () => {
+ let mockCreateObjectURL: jest.SpyInstance;
+ let mockRevokeObjectURL: jest.SpyInstance;
+ let mockRemove: jest.Mock;
+ let mockClick: jest.Mock;
+ let createdAnchor: HTMLAnchorElement | null = null;
+
+ beforeEach(() => {
+ mockCreateObjectURL = jest
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue('blob:mock-url');
+ mockRevokeObjectURL = jest
+ .spyOn(URL, 'revokeObjectURL')
+ .mockImplementation(() => {});
+
+ mockRemove = jest.fn();
+ mockClick = jest.fn();
+
+ jest
+ .spyOn(document, 'createElement')
+ .mockImplementation((tag: string) => {
+ if (tag === 'a') {
+ createdAnchor = {
+ href: '',
+ download: '',
+ click: mockClick,
+ remove: mockRemove,
+ } as unknown as HTMLAnchorElement;
+ return createdAnchor;
+ }
+ return document.createElement(tag);
+ });
+
+ jest.spyOn(document.body, 'appendChild').mockImplementation(node => node);
+
+ // Ensure showSaveFilePicker is not available for fallback tests
+ delete (window as any).showSaveFilePicker;
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ createdAnchor = null;
+ });
+
+ it('downloads stream using blob fallback when File System Access API is unavailable', async () => {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('test content'));
+ controller.close();
+ },
+ });
+
+ await streamDownload(stream, 'test.txt', 'text/plain');
+
+ expect(mockCreateObjectURL).toHaveBeenCalledTimes(1);
+ expect(createdAnchor?.download).toBe('test.txt');
+ expect(mockClick).toHaveBeenCalledTimes(1);
+ expect(mockRemove).toHaveBeenCalledTimes(1);
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ });
+
+ it('sets correct filename on anchor element', async () => {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('csv data'));
+ controller.close();
+ },
+ });
+
+ await streamDownload(stream, 'export.csv', 'text/csv; charset=utf-8');
+
+ expect(createdAnchor?.download).toBe('export.csv');
+ });
+
+ it('cleans up blob URL after download', async () => {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data'));
+ controller.close();
+ },
+ });
+
+ await streamDownload(stream, 'file.json', 'application/json');
+
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+ });
+
+ describe('with File System Access API', () => {
+ let mockWritable: { close: jest.Mock };
+ let mockHandle: { createWritable: jest.Mock };
+ let mockShowSaveFilePicker: jest.Mock;
+
+ beforeEach(() => {
+ mockWritable = {
+ close: jest.fn(),
+ };
+ mockHandle = {
+ createWritable: jest.fn().mockResolvedValue(mockWritable),
+ };
+ mockShowSaveFilePicker = jest.fn().mockResolvedValue(mockHandle);
+ (window as any).showSaveFilePicker = mockShowSaveFilePicker;
+ });
+
+ it('uses File System Access API when available', async () => {
+ const encoder = new TextEncoder();
+ const chunks = [encoder.encode('chunk1'), encoder.encode('chunk2')];
+ let chunkIndex = 0;
+
+ const stream = new ReadableStream({
+ pull(controller) {
+ if (chunkIndex < chunks.length) {
+ controller.enqueue(chunks[chunkIndex++]);
+ } else {
+ controller.close();
+ }
+ },
+ });
+
+ // Mock pipeTo to simulate streaming
+ stream.pipeTo = jest.fn().mockResolvedValue(undefined);
+
+ await streamDownload(stream, 'export.csv', 'text/csv');
+
+ expect(mockShowSaveFilePicker).toHaveBeenCalledWith({
+ suggestedName: 'export.csv',
+ types: [
+ {
+ description: 'Export file',
+ accept: { 'text/csv': ['.csv'] },
+ },
+ ],
+ });
+ expect(mockHandle.createWritable).toHaveBeenCalled();
+ expect(stream.pipeTo).toHaveBeenCalledWith(mockWritable);
+
+ // Should not fall back to blob download
+ expect(mockCreateObjectURL).not.toHaveBeenCalled();
+ });
+
+ it('handles user cancellation gracefully', async () => {
+ const abortError = new Error('User cancelled');
+ abortError.name = 'AbortError';
+ mockShowSaveFilePicker.mockRejectedValue(abortError);
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data'));
+ controller.close();
+ },
+ });
+
+ // Should not throw and should not fall back
+ await expect(
+ streamDownload(stream, 'file.txt', 'text/plain'),
+ ).resolves.toBeUndefined();
+
+ expect(mockCreateObjectURL).not.toHaveBeenCalled();
+ });
+
+ it('falls back to blob download on other errors', async () => {
+ const otherError = new Error('Some other error');
+ otherError.name = 'NotAllowedError';
+ mockShowSaveFilePicker.mockRejectedValue(otherError);
+
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode('data'));
+ controller.close();
+ },
+ });
+
+ await streamDownload(stream, 'file.txt', 'text/plain');
+
+ // Should fall back to blob download
+ expect(mockCreateObjectURL).toHaveBeenCalled();
+ expect(mockClick).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.ts
new file mode 100644
index 0000000000..13a76ba944
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/downloadFile.ts
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+
+/**
+ * Initiates a streaming download using the Streams API.
+ * Uses streamSaver-style approach with a service worker when available,
+ * falls back to blob download for browsers without full streaming support.
+ */
+export const streamDownload = async (
+ stream: ReadableStream,
+ filename: string,
+ contentType: string,
+): Promise => {
+ // Try to use the modern streaming approach with polyfill for broader support
+ // This creates a WritableStream that pipes to a download
+ if ('showSaveFilePicker' in window) {
+ try {
+ const handle = await (window as any).showSaveFilePicker({
+ suggestedName: filename,
+ types: [
+ {
+ description: 'Export file',
+ accept: {
+ [contentType.split(';')[0]]: [`.${filename.split('.').pop()}`],
+ },
+ },
+ ],
+ });
+ const writable = await handle.createWritable();
+ await stream.pipeTo(writable);
+ return;
+ } catch (e: any) {
+ // User cancelled or API not fully supported, fall back
+ if (e.name === 'AbortError') {
+ return;
+ }
+ }
+ }
+
+ // Fallback: collect stream into blob (less memory efficient but widely supported)
+ const response = new Response(stream, {
+ headers: { 'Content-Type': contentType },
+ });
+
+ const blob = await response.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+
+ URL.revokeObjectURL(blobUrl);
+};
+
+/**
+ * Creates a ReadableStream from an async generator that yields string chunks.
+ * This allows streaming serialization directly to the download.
+ */
+export const createStreamFromAsyncGenerator = (
+ generator: AsyncGenerator,
+): ReadableStream => {
+ const encoder = new TextEncoder();
+
+ return new ReadableStream({
+ async pull(controller) {
+ const result = await generator.next();
+ if (result.done) {
+ controller.close();
+ } else {
+ controller.enqueue(encoder.encode(result.value as string));
+ }
+ },
+ });
+};
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts
new file mode 100644
index 0000000000..b383f99cc0
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.test.ts
@@ -0,0 +1,228 @@
+/*
+ * 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 { filtersToStreamRequest } from './filtersToStreamRequest';
+import { DefaultEntityFilters } from '@backstage/plugin-catalog-react';
+
+describe('filtersToStreamRequest', () => {
+ describe('with no filters', () => {
+ it('returns undefined for empty filters object', () => {
+ const filters: DefaultEntityFilters = {};
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('with backend filters', () => {
+ it('extracts backend filters and returns StreamEntitiesRequest', () => {
+ const mockFilter = {
+ getCatalogFilters: () => ({
+ kind: ['Component'],
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component'],
+ },
+ });
+ });
+
+ it('merges multiple backend filters', () => {
+ const mockKindFilter = {
+ getCatalogFilters: () => ({
+ kind: ['Component'],
+ }),
+ };
+
+ const mockTypeFilter = {
+ getCatalogFilters: () => ({
+ type: 'service',
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockKindFilter as any,
+ type: mockTypeFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component'],
+ type: 'service',
+ },
+ });
+ });
+
+ it('handles array values from backend filters', () => {
+ const mockFilter = {
+ getCatalogFilters: () => ({
+ kind: ['Component', 'API', 'Domain'],
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component', 'API', 'Domain'],
+ },
+ });
+ });
+
+ it('ignores filters without getCatalogFilters method', () => {
+ const backendFilter = {
+ getCatalogFilters: () => ({
+ kind: ['Component'],
+ }),
+ };
+
+ const nonBackendFilter = {
+ someOtherMethod: () => ({}),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: backendFilter as any,
+ text: nonBackendFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component'],
+ },
+ });
+ });
+
+ it('ignores filters that return null from getCatalogFilters', () => {
+ const mockFilterWithNull = {
+ getCatalogFilters: () => null,
+ };
+
+ const mockFilterWithValue = {
+ getCatalogFilters: () => ({
+ kind: ['Component'],
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockFilterWithValue as any,
+ owners: mockFilterWithNull as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component'],
+ },
+ });
+ });
+
+ it('excludes empty arrays from filter result', () => {
+ const mockFilter = {
+ getCatalogFilters: () => ({
+ kind: [],
+ type: 'service',
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ type: 'service',
+ },
+ });
+ });
+
+ it('excludes undefined and null values from filter result', () => {
+ const mockFilter = {
+ getCatalogFilters: () => ({
+ kind: ['Component'],
+ type: undefined,
+ owner: null,
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ kind: ['Component'],
+ },
+ });
+ });
+
+ it('returns undefined when all filters have no backend equivalent', () => {
+ const mockEmptyFilter = {
+ getCatalogFilters: () => ({}),
+ };
+
+ const filters: DefaultEntityFilters = {
+ kind: mockEmptyFilter as any,
+ type: mockEmptyFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('handles complex filter types with special characters', () => {
+ const mockFilter = {
+ getCatalogFilters: () => ({
+ 'spec.type': 'my-service',
+ 'metadata.namespace': 'default',
+ }),
+ };
+
+ const filters: DefaultEntityFilters = {
+ type: mockFilter as any,
+ };
+
+ const result = filtersToStreamRequest(filters);
+
+ expect(result).toEqual({
+ filter: {
+ 'spec.type': 'my-service',
+ 'metadata.namespace': 'default',
+ },
+ });
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts
new file mode 100644
index 0000000000..fae6c9ef7d
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/filtersToStreamRequest.ts
@@ -0,0 +1,75 @@
+/*
+ * 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 { DefaultEntityFilters } from '@backstage/plugin-catalog-react';
+import type { StreamEntitiesRequest } from '@backstage/catalog-client';
+
+function isBackendFilter(f: any): f is { getCatalogFilters: () => any } {
+ return typeof f?.getCatalogFilters === 'function';
+}
+
+function getBackendFilterObject(
+ backendFilter: Record,
+): Record {
+ const result: Record = {};
+
+ for (const [backendFilterName, backendFilterValue] of Object.entries(
+ backendFilter,
+ )) {
+ if (Array.isArray(backendFilterValue)) {
+ if (backendFilterValue.length > 0) {
+ result[backendFilterName] = backendFilterValue;
+ }
+ } else if (
+ backendFilterValue !== undefined &&
+ backendFilterValue !== null
+ ) {
+ result[backendFilterName] = backendFilterValue;
+ }
+ }
+
+ return result;
+}
+
+/**
+ * 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.
+ *
+ * @param filters - The entity filters from useEntityList
+ * @returns A StreamEntitiesRequest object, or undefined if no backend filters are enabled
+ */
+export const filtersToStreamRequest = (
+ filters: DefaultEntityFilters,
+): StreamEntitiesRequest | undefined => {
+ const backendFilters = Object.values(filters)
+ .flatMap(f => {
+ if (isBackendFilter(f)) {
+ const backendFilter = f.getCatalogFilters();
+ return backendFilter ? [backendFilter] : [];
+ }
+ return [];
+ })
+ .reduce((acc, f) => {
+ return { ...acc, ...getBackendFilterObject(f) };
+ }, {} as Record);
+
+ // Return undefined if no filters, which means stream all entities
+ return Object.keys(backendFilters).length > 0
+ ? { filter: backendFilters }
+ : undefined;
+};
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts
new file mode 100644
index 0000000000..260182acb5
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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.
+ */
+export * from './useStreamingExport';
+export * from './serializeEntities';
+export * from './filtersToStreamRequest';
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.test.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.test.ts
new file mode 100644
index 0000000000..23c6ab9450
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.test.ts
@@ -0,0 +1,234 @@
+/*
+ * 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 { Entity } from '@backstage/catalog-model';
+import {
+ getEntityDataFromColumns,
+ getColumnTitle,
+ serializeEntitiesToCsv,
+ serializeEntityToJsonRow,
+ CatalogExportSettingsColumn,
+} from './serializeEntities';
+
+describe('getColumnTitle', () => {
+ it('returns title when provided', () => {
+ expect(
+ getColumnTitle({ entityFilterKey: 'metadata.name', title: 'Name' }),
+ ).toBe('Name');
+ });
+
+ it('falls back to entityFilterKey when title is omitted', () => {
+ expect(getColumnTitle({ entityFilterKey: 'metadata.name' })).toBe(
+ 'metadata.name',
+ );
+ });
+});
+
+describe('serializeEntities', () => {
+ const testColumns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'spec.type', title: 'Type' },
+ { entityFilterKey: 'spec.owner', title: 'Owner' },
+ ];
+
+ const testEntity: Entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'test-component',
+ namespace: 'default',
+ },
+ spec: {
+ type: 'service',
+ owner: 'team-a',
+ description: 'A test component',
+ },
+ };
+
+ describe('getEntityDataFromColumns', () => {
+ it('extracts entity data based on columns', () => {
+ const result = getEntityDataFromColumns(testEntity, testColumns);
+
+ expect(result).toEqual({
+ Name: 'test-component',
+ Type: 'service',
+ Owner: 'team-a',
+ });
+ });
+
+ it('handles missing nested properties', () => {
+ const entityWithoutOwner = { ...testEntity, spec: { type: 'service' } };
+ const result = getEntityDataFromColumns(entityWithoutOwner, testColumns);
+
+ expect(result).toEqual({
+ Name: 'test-component',
+ Type: 'service',
+ Owner: undefined,
+ });
+ });
+
+ it('uses entityFilterKey as key when title is omitted', () => {
+ const columns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'metadata.name' },
+ { entityFilterKey: 'spec.type' },
+ ];
+ const result = getEntityDataFromColumns(testEntity, columns);
+ expect(result).toEqual({
+ 'metadata.name': 'test-component',
+ 'spec.type': 'service',
+ });
+ });
+
+ it('handles deeply nested paths', () => {
+ const columns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'metadata.annotations.foo', title: 'FooAnnotation' },
+ ];
+ const entity = {
+ ...testEntity,
+ metadata: {
+ ...testEntity.metadata,
+ annotations: {
+ foo: 'bar',
+ },
+ },
+ };
+
+ const result = getEntityDataFromColumns(entity, columns);
+ expect(result).toEqual({ FooAnnotation: 'bar' });
+ });
+ });
+
+ describe('serializeEntitiesToCsv', () => {
+ it('serializes entities to CSV format with headers', () => {
+ const entities = [testEntity];
+ const csv = serializeEntitiesToCsv(entities, testColumns);
+
+ expect(csv).toContain('Name');
+ expect(csv).toContain('Type');
+ expect(csv).toContain('Owner');
+ expect(csv).toContain('test-component');
+ expect(csv).toContain('service');
+ expect(csv).toContain('team-a');
+ });
+
+ it('serializes entities to CSV format without headers when addHeader is false', () => {
+ const entities = [testEntity];
+ const csv = serializeEntitiesToCsv(entities, testColumns, false);
+
+ // Should not contain column headers
+ expect(csv).not.toContain('Name,Type,Owner');
+ // But should contain data
+ expect(csv).toContain('test-component');
+ expect(csv).toContain('service');
+ expect(csv).toContain('team-a');
+ });
+
+ it('handles multiple entities', () => {
+ const entity2: Entity = {
+ ...testEntity,
+ metadata: { ...testEntity.metadata, name: 'another-component' },
+ spec: { ...testEntity.spec, type: 'library' },
+ };
+
+ const csv = serializeEntitiesToCsv([testEntity, entity2], testColumns);
+
+ expect(csv).toContain('test-component');
+ expect(csv).toContain('another-component');
+ expect(csv).toContain('service');
+ expect(csv).toContain('library');
+ });
+
+ it('escapes newlines in CSV values', () => {
+ const entity = {
+ ...testEntity,
+ spec: {
+ ...testEntity.spec,
+ description: 'Line 1\nLine 2\rLine 3\r\nLine 4',
+ },
+ };
+
+ const columns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'spec.description', title: 'Description' },
+ ];
+
+ const csv = serializeEntitiesToCsv([entity], columns);
+
+ // Newlines should be escaped
+ expect(csv).toContain('Line 1\\nLine 2\\nLine 3\\nLine 4');
+ });
+
+ it('protects against CSV formula injection', () => {
+ const columns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'spec.formulaTest1', title: 'Test1' },
+ { entityFilterKey: 'spec.formulaTest2', title: 'Test2' },
+ { entityFilterKey: 'spec.formulaTest3', title: 'Test3' },
+ { entityFilterKey: 'spec.formulaTest4', title: 'Test4' },
+ ];
+
+ const entity = {
+ ...testEntity,
+ spec: {
+ ...testEntity.spec,
+ formulaTest1: '=1+1',
+ formulaTest2: '+1+1',
+ formulaTest3: '-2+3',
+ formulaTest4: '@SUM(A1:A10)',
+ },
+ };
+
+ const csv = serializeEntitiesToCsv([entity], columns);
+
+ // Formula injection attempts should be prefixed with a single quote
+ expect(csv).toContain("'=1+1");
+ expect(csv).toContain("'+1+1");
+ expect(csv).toContain("'-2+3");
+ expect(csv).toContain("'@SUM(A1:A10)");
+ });
+
+ it('does not modify values that do not start with formula characters', () => {
+ const columns: CatalogExportSettingsColumn[] = [
+ { entityFilterKey: 'spec.owner', title: 'Owner' },
+ ];
+
+ const csv = serializeEntitiesToCsv([testEntity], columns);
+
+ // Regular values should not have a leading quote
+ expect(csv).toContain('team-a');
+ expect(csv).not.toContain("'team-a");
+ });
+ });
+
+ describe('serializeEntitiesToJson', () => {
+ it('serializes entities to JSON format', () => {
+ const entities = [testEntity];
+ const json = serializeEntityToJsonRow(entities[0], testColumns);
+
+ const parsed = JSON.parse(json);
+ expect(parsed).toEqual({
+ Name: 'test-component',
+ Type: 'service',
+ Owner: 'team-a',
+ });
+ });
+
+ it('formats JSON with proper indentation', () => {
+ const json = serializeEntityToJsonRow(testEntity, testColumns);
+
+ // Should have indentation (2 spaces)
+ expect(json).toContain(' ');
+ expect(json).toMatch(/{\n\s{2}"/);
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.ts
new file mode 100644
index 0000000000..2e79e821bd
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/serializeEntities.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { Entity } from '@backstage/catalog-model';
+import stringifySync from 'csv-stringify/lib/sync';
+
+/**
+ * Defines a column to include in a catalog export.
+ *
+ * @public
+ */
+export interface CatalogExportSettingsColumn {
+ /**
+ * Dot-notation path to the entity field to export (e.g. `metadata.name`, `spec.owner`).
+ * Also used as the column title when `title` is omitted.
+ */
+ entityFilterKey: string;
+ /** Column header shown in the exported file. Defaults to `entityFilterKey` when omitted. */
+ title?: string;
+}
+
+export const getColumnTitle = (col: CatalogExportSettingsColumn): string =>
+ col.title ?? col.entityFilterKey;
+
+const getByPath = (obj: any, path: string): unknown => {
+ return path
+ .split('.')
+ .reduce(
+ (acc, part) =>
+ acc === null || acc === undefined ? undefined : acc[part],
+ obj,
+ );
+};
+
+const escapeCsvValue = (value: unknown): string => {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ const str = String(value);
+
+ // Preserve newlines, as the JSON exporter does this as well
+ let safe = str.replace(/(\r\n|\n|\r)/gm, '\\n');
+
+ // Prevent CSV / formula injection
+ if (/^[=+\-@]/.test(safe)) {
+ safe = `'${safe}`;
+ }
+
+ return safe;
+};
+
+export const getEntityDataFromColumns = (
+ entity: Entity,
+ columns: CatalogExportSettingsColumn[],
+) => {
+ const mappedData: Record = {};
+ for (const col of columns) {
+ mappedData[getColumnTitle(col)] = getByPath(entity, col.entityFilterKey);
+ }
+ return mappedData;
+};
+
+export const serializeEntitiesToCsv = (
+ entities: Entity[],
+ columns: CatalogExportSettingsColumn[],
+ addHeader: boolean = true,
+): string => {
+ const rows = entities.map(e => getEntityDataFromColumns(e, columns));
+ return stringifySync(rows, {
+ header: addHeader,
+ columns: columns.map(c => ({
+ key: getColumnTitle(c),
+ header: getColumnTitle(c),
+ })),
+ cast: {
+ string: escapeCsvValue,
+ },
+ });
+};
+
+export const serializeEntityToJsonRow = (
+ entity: Entity,
+ columns: CatalogExportSettingsColumn[],
+): string => {
+ const row = getEntityDataFromColumns(entity, columns);
+ return JSON.stringify(row, null, 2);
+};
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.test.tsx b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.test.tsx
new file mode 100644
index 0000000000..e1f00ad500
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.test.tsx
@@ -0,0 +1,333 @@
+/*
+ * 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 { renderHook, waitFor, act } from '@testing-library/react';
+import { Entity } from '@backstage/catalog-model';
+import { TestApiProvider } from '@backstage/test-utils';
+import { EntityListProvider } from '@backstage/plugin-catalog-react';
+import { MemoryRouter } from 'react-router-dom';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+import { useStreamingExport } from './useStreamingExport';
+import { CatalogExportType } from '../CatalogExportButton';
+import * as downloadBlobModule from './downloadFile';
+
+// Store collected content for test assertions
+let lastCollectedContent = '';
+
+jest.mock('./downloadFile', () => ({
+ streamDownload: jest.fn(async (stream: any) => {
+ // Actually consume the stream to trigger any errors in the generator
+ if (stream && stream.collect) {
+ lastCollectedContent = await stream.collect();
+ }
+ }),
+ createStreamFromAsyncGenerator: jest.fn(generator => {
+ // Return a mock ReadableStream that collects the generator output
+ return {
+ _generator: generator,
+ async collect() {
+ const chunks: string[] = [];
+ // Iterate the generator - errors will propagate
+ for await (const value of this._generator) {
+ chunks.push(value);
+ }
+ return chunks.join('');
+ },
+ };
+ }),
+}));
+
+const mockStreamDownload = downloadBlobModule.streamDownload as jest.Mock;
+const getLastCollectedContent = () => lastCollectedContent;
+
+describe('useStreamingExport', () => {
+ const testEntity: Entity = {
+ apiVersion: 'backstage.io/v1alpha1',
+ kind: 'Component',
+ metadata: {
+ name: 'test-component',
+ namespace: 'default',
+ },
+ spec: {
+ type: 'service',
+ owner: 'team-a',
+ },
+ };
+
+ const testColumns = [
+ { entityFilterKey: 'metadata.name', title: 'Name' },
+ { entityFilterKey: 'spec.type', title: 'Type' },
+ ];
+
+ const createMockCatalogApi = (entities: Entity[][], shouldError = false) => ({
+ streamEntities: jest.fn(function streamEntitiesGenerator() {
+ // Return an async generator
+ return (async function* streamGenerator() {
+ if (shouldError) {
+ throw new Error('Stream error');
+ }
+ for (const page of entities) {
+ yield page;
+ }
+ })();
+ }),
+ });
+
+ const renderHookWithApi = (catalogApi: any) => {
+ const wrapper = ({ children }: any) => (
+
+
+ {children}
+
+
+ );
+ return renderHook(() => useStreamingExport(), { wrapper });
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ lastCollectedContent = '';
+ });
+
+ describe('exportStream', () => {
+ it('exports entities to CSV format', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const callArgs = mockStreamDownload.mock.calls[0];
+ const filename = callArgs[1] as string;
+ const contentType = callArgs[2] as string;
+
+ expect(filename).toBe('test.csv');
+ expect(contentType).toBe('text/csv; charset=utf-8');
+ const content = getLastCollectedContent();
+ expect(content).toContain('Name');
+ expect(content).toContain('Type');
+ expect(content).toContain('test-component');
+ expect(content).toContain('service');
+ });
+
+ it('exports entities to JSON format', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.JSON,
+ filename: 'test.json',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const callArgs = mockStreamDownload.mock.calls[0];
+ const filename = callArgs[1] as string;
+ const contentType = callArgs[2] as string;
+
+ expect(filename).toBe('test.json');
+ expect(contentType).toBe('application/json; charset=utf-8');
+ const content = getLastCollectedContent();
+ const parsed = JSON.parse(content);
+ expect(parsed).toEqual([
+ {
+ Name: 'test-component',
+ Type: 'service',
+ },
+ ]);
+ });
+
+ it('handles multiple pages of entities with proper header handling', async () => {
+ const entity1 = { ...testEntity };
+ const entity2 = {
+ ...testEntity,
+ metadata: { ...testEntity.metadata, name: 'another-component' },
+ };
+
+ const catalogApi = createMockCatalogApi([[entity1], [entity2]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const content = getLastCollectedContent();
+
+ // Should have both entities, headers should appear only once
+ expect(content).toContain('test-component');
+ expect(content).toContain('another-component');
+ const headerCount = (content.match(/Name,Type/g) || []).length;
+ expect(headerCount).toBe(1);
+ });
+
+ it('passes streamRequest to catalogApi.streamEntities', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ const streamRequest = { filter: { kind: 'Component' } };
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ streamRequest,
+ });
+ });
+
+ expect(catalogApi.streamEntities).toHaveBeenCalledWith(streamRequest);
+ });
+
+ it('sets loading state correctly', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ expect(result.current.loading).toBe(false);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('sets error state on failure', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]], true);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ expect(result.current.error?.message).toBe('Stream error');
+ });
+ });
+
+ it('sets correct content type for CSV', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const callArgs = mockStreamDownload.mock.calls[0];
+ const contentType = callArgs[2] as string;
+ expect(contentType).toContain('text/csv');
+ });
+
+ it('sets correct content type for JSON', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.JSON,
+ filename: 'test.json',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const callArgs = mockStreamDownload.mock.calls[0];
+ const contentType = callArgs[2] as string;
+ expect(contentType).toContain('application/json');
+ });
+
+ it('handles empty entity stream', async () => {
+ const catalogApi = createMockCatalogApi([]);
+ const { result } = renderHookWithApi(catalogApi);
+
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.JSON,
+ filename: 'test.json',
+ columns: testColumns,
+ });
+ });
+
+ expect(mockStreamDownload).toHaveBeenCalledTimes(1);
+
+ const content = getLastCollectedContent();
+ expect(content).toBe('[\n]');
+ });
+
+ it('clears previous error on new export', async () => {
+ const catalogApi = createMockCatalogApi([[testEntity]], true);
+ const { result } = renderHookWithApi(catalogApi);
+
+ // First export fails
+ await act(async () => {
+ await result.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).not.toBeNull();
+ });
+
+ // Create new catalog API that succeeds
+ const successCatalogApi = createMockCatalogApi([[testEntity]]);
+ const { result: result2 } = renderHookWithApi(successCatalogApi);
+
+ // Second export succeeds
+ await act(async () => {
+ await result2.current.exportStream({
+ exportFormat: CatalogExportType.CSV,
+ filename: 'test.csv',
+ columns: testColumns,
+ });
+ });
+
+ expect(result2.current.error).toBeNull();
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts
new file mode 100644
index 0000000000..8dd23646bf
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/file-download/useStreamingExport.ts
@@ -0,0 +1,202 @@
+/*
+ * 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 { useState, useCallback } from 'react';
+import { useApiHolder, type ApiHolder } from '@backstage/core-plugin-api';
+import { catalogApiRef, useEntityList } from '@backstage/plugin-catalog-react';
+import { streamDownload, createStreamFromAsyncGenerator } from './downloadFile';
+import {
+ serializeEntitiesToCsv,
+ serializeEntityToJsonRow,
+ CatalogExportSettingsColumn,
+} from './serializeEntities';
+import { CatalogExportType } from '../CatalogExportButton';
+import type { StreamEntitiesRequest } from '@backstage/catalog-client';
+import { filtersToStreamRequest } from './filtersToStreamRequest';
+
+/**
+ * A custom exporter function that returns an async generator for streaming exports.
+ * The generator should yield string chunks that will be streamed to the download.
+ * @public
+ */
+export type CatalogExporter = (options: {
+ apis: ApiHolder;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+}) => {
+ generator: AsyncGenerator;
+ contentType: string;
+};
+
+/**
+ * @public
+ */
+export interface StreamingExportOptions {
+ exportFormat: CatalogExportType | string;
+ filename: string;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ exporter?: CatalogExporter;
+}
+
+/**
+ * Async generator that streams and serializes catalog entities to CSV format.
+ * Yields CSV chunks as they're generated, enabling streaming downloads.
+ */
+async function* streamEntitiesCsvGenerator({
+ catalogApi,
+ columns,
+ streamRequest,
+}: {
+ catalogApi: any;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+}): AsyncGenerator {
+ let isFirstPage = true;
+
+ const entityStream = catalogApi.streamEntities(streamRequest);
+
+ for await (const entityPage of entityStream) {
+ const pageCsv = serializeEntitiesToCsv(entityPage, columns, isFirstPage);
+ yield pageCsv;
+ isFirstPage = false;
+ }
+}
+
+/**
+ * Async generator that streams and serializes catalog entities to JSON format.
+ * Yields JSON chunks as they're generated, enabling streaming downloads.
+ */
+async function* streamEntitiesJsonGenerator({
+ catalogApi,
+ columns,
+ streamRequest,
+}: {
+ catalogApi: any;
+ columns: CatalogExportSettingsColumn[];
+ streamRequest?: StreamEntitiesRequest;
+}): AsyncGenerator {
+ let isFirst = true;
+
+ yield '[';
+
+ const entityStream = catalogApi.streamEntities(streamRequest);
+
+ for await (const entityPage of entityStream) {
+ for (const entity of entityPage) {
+ const row = serializeEntityToJsonRow(entity, columns);
+ if (isFirst) {
+ yield `\n ${row}`;
+ isFirst = false;
+ } else {
+ yield `,\n ${row}`;
+ }
+ }
+ }
+
+ yield '\n]';
+}
+
+/**
+ * A hook for streaming and exporting catalog entities from the frontend.
+ *
+ * This hook uses the catalog API's `streamEntities` method to efficiently
+ * stream entities in pages and serialize them to CSV or JSON format directly
+ * on the frontend, without requiring a backend export endpoint.
+ *
+ * @returns An object containing:
+ * - `exportStream`: The function to trigger the streaming export.
+ * - `loading`: A boolean indicating if the export is in progress.
+ * - `error`: An error object if the export fails.
+ */
+export const useStreamingExport = (): {
+ exportStream: (options: StreamingExportOptions) => Promise;
+ loading: boolean;
+ error: Error | null;
+} => {
+ const apis = useApiHolder();
+ const catalogApi = apis.get(catalogApiRef)!;
+ const { filters } = useEntityList();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const exportStream = useCallback(
+ async ({
+ exportFormat,
+ filename,
+ columns,
+ streamRequest,
+ onSuccess,
+ onError,
+ exporter: exporterFn,
+ }: StreamingExportOptions) => {
+ setLoading(true);
+ setError(null);
+
+ 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);
+
+ if (exporterFn) {
+ const { generator, contentType } = exporterFn({
+ apis,
+ columns,
+ streamRequest: resolvedStreamRequest,
+ });
+ const stream = createStreamFromAsyncGenerator(generator);
+ await streamDownload(stream, filename, contentType);
+ } else if (exportFormat === CatalogExportType.CSV) {
+ const generator = streamEntitiesCsvGenerator({
+ catalogApi,
+ columns,
+ streamRequest: resolvedStreamRequest,
+ });
+ const contentType = 'text/csv; charset=utf-8';
+ const stream = createStreamFromAsyncGenerator(generator);
+ await streamDownload(stream, filename, contentType);
+ } else if (exportFormat === CatalogExportType.JSON) {
+ const generator = streamEntitiesJsonGenerator({
+ catalogApi,
+ columns,
+ streamRequest: resolvedStreamRequest,
+ });
+ const contentType = 'application/json; charset=utf-8';
+ const stream = createStreamFromAsyncGenerator(generator);
+ await streamDownload(stream, filename, contentType);
+ } else {
+ throw new Error(`Unsupported export format: ${exportFormat}`);
+ }
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (e: any) {
+ setError(e);
+ if (onError) {
+ onError(e);
+ }
+ } finally {
+ setLoading(false);
+ }
+ },
+ [filters, apis, catalogApi],
+ );
+
+ return { exportStream, loading, error };
+};
diff --git a/plugins/catalog/src/components/CatalogExportButton/index.ts b/plugins/catalog/src/components/CatalogExportButton/index.ts
new file mode 100644
index 0000000000..ca65015005
--- /dev/null
+++ b/plugins/catalog/src/components/CatalogExportButton/index.ts
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+export type {
+ CatalogExportSettings,
+ CatalogExporterConfig,
+} from './CatalogExportButton';
+export type { CatalogExportSettingsColumn } from './file-download/serializeEntities';
+export type { CatalogExporter } from './file-download/useStreamingExport';
diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
index ed76aa49b2..286bf85170 100644
--- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx
@@ -41,29 +41,32 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { CatalogTableColumnsFunc } from '../CatalogTable/types';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
import { usePermission } from '@backstage/plugin-permission-react';
+import { CatalogExportButton } from '../CatalogExportButton/CatalogExportButton';
+import type { CatalogExportSettings } from '../CatalogExportButton';
+import Box from '@material-ui/core/Box';
/** @internal */
export type BaseCatalogPageProps = {
filters: ReactNode;
content?: ReactNode;
pagination?: EntityListPagination;
+ exportSettings?: CatalogExportSettings;
};
function CatalogPageContent(props: BaseCatalogPageProps) {
- const { filters, content = , pagination } = props;
+ const { filters, content = } = props;
return (
-
-
- {filters}
- {content}
-
-
+
+ {filters}
+ {content}
+
);
}
/** @internal */
export function BaseCatalogPage(props: BaseCatalogPageProps) {
+ const { pagination, exportSettings } = props;
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
const createComponentLink = useRouteRef(createComponentRouteRef);
@@ -79,21 +82,29 @@ export function BaseCatalogPage(props: BaseCatalogPageProps) {
to={createComponentLink && createComponentLink()}
/>
)}
+ {exportSettings?.enabled && (
+
+
+
+ )}
{t('indexPage.supportButtonContent')}
>
);
return (
-
-
- {headerActions}
-
-
-
+
+
+
+ {headerActions}
+
+
+
+
);
}
function NfsBaseCatalogPage(props: BaseCatalogPageProps) {
+ const { pagination, exportSettings } = props;
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Backstage';
const createComponentLink = useRouteRef(createComponentRouteRef);
@@ -103,7 +114,7 @@ function NfsBaseCatalogPage(props: BaseCatalogPageProps) {
});
return (
- <>
+
)}
+ {exportSettings?.enabled && (
+
+
+
+ )}
{t('indexPage.supportButtonContent')}
>
}
@@ -121,7 +137,7 @@ function NfsBaseCatalogPage(props: BaseCatalogPageProps) {
- >
+
);
}
@@ -141,6 +157,7 @@ export interface DefaultCatalogPageProps {
filters?: ReactNode;
initiallySelectedNamespaces?: string[];
pagination?: EntityListPagination;
+ exportSettings?: CatalogExportSettings;
}
export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
@@ -155,6 +172,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
ownerPickerMode,
filters,
initiallySelectedNamespaces,
+ exportSettings,
} = props;
return (
@@ -178,6 +196,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
/>
}
pagination={pagination}
+ exportSettings={exportSettings}
/>
);
}
@@ -195,6 +214,7 @@ export function NfsDefaultCatalogPage(props: DefaultCatalogPageProps) {
ownerPickerMode,
filters,
initiallySelectedNamespaces,
+ exportSettings,
} = props;
return (
@@ -218,6 +238,7 @@ export function NfsDefaultCatalogPage(props: DefaultCatalogPageProps) {
/>
}
pagination={pagination}
+ exportSettings={exportSettings}
/>
);
}
diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts
index 2c1e81f803..2801d0be85 100644
--- a/plugins/catalog/src/index.ts
+++ b/plugins/catalog/src/index.ts
@@ -100,4 +100,5 @@ export type {
} from './components/HasSystemsCard';
export type { RelatedEntitiesCardProps } from './components/RelatedEntitiesCard';
export type { CatalogSearchResultListItemProps } from './components/CatalogSearchResultListItem';
+export * from './components/CatalogExportButton';
export { catalogTranslationRef } from './alpha/translation';
diff --git a/plugins/mcp-actions-backend/package.json b/plugins/mcp-actions-backend/package.json
index ffc4d0da70..32fded2f2f 100644
--- a/plugins/mcp-actions-backend/package.json
+++ b/plugins/mcp-actions-backend/package.json
@@ -21,7 +21,6 @@
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
- "configSchema": "config.d.ts",
"files": [
"dist",
"config.d.ts"
@@ -56,5 +55,6 @@
"@types/express": "^4.17.6",
"@types/supertest": "^2.0.8",
"supertest": "^7.0.0"
- }
+ },
+ "configSchema": "config.d.ts"
}
diff --git a/yarn.lock b/yarn.lock
index 6802845bac..8673ff3cb8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5496,6 +5496,7 @@ __metadata:
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@mui/utils": "npm:^5.14.15"
+ "@remixicon/react": "npm:^4.6.0"
"@testing-library/dom": "npm:^10.0.0"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"
@@ -5503,6 +5504,7 @@ __metadata:
"@types/pluralize": "npm:^0.0.33"
"@types/react": "npm:^18.0.0"
classnames: "npm:^2.3.1"
+ csv-stringify: "npm:^5.6.5"
dataloader: "npm:^2.0.0"
lodash: "npm:^4.17.21"
pluralize: "npm:^8.0.0"
@@ -26504,6 +26506,13 @@ __metadata:
languageName: node
linkType: hard
+"csv-stringify@npm:^5.6.5":
+ version: 5.6.5
+ resolution: "csv-stringify@npm:5.6.5"
+ checksum: 10/efed94869b8426e6a983f2237bd74eff15953e2e27affee9c1324f66a67dabe948573c4c21a8661a79aa20b58efbcafcf11c34e80bdd532a43f35e9cde5985b9
+ languageName: node
+ linkType: hard
+
"ctrlc-windows@npm:^2.1.0":
version: 2.2.0
resolution: "ctrlc-windows@npm:2.2.0"