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 ( + <> + + + !isOpen && setOpen(false)}> + {t('catalogExportButton.dialogTitle')} + + +