diff --git a/.changeset/chilled-bugs-draw.md b/.changeset/chilled-bugs-draw.md new file mode 100644 index 0000000000..2ef96badd1 --- /dev/null +++ b/.changeset/chilled-bugs-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The default auditor service implementation will now log low severity events with `debug` level instead of `info`. diff --git a/.changeset/dirty-turkeys-remain.md b/.changeset/dirty-turkeys-remain.md new file mode 100644 index 0000000000..f9119e3880 --- /dev/null +++ b/.changeset/dirty-turkeys-remain.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes': patch +--- + +refactor: use `KUBERNETES_ANNOTATION` and `KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION` annotations from `kubernetes-common` diff --git a/.changeset/dull-coats-help.md b/.changeset/dull-coats-help.md new file mode 100644 index 0000000000..60a099da2e --- /dev/null +++ b/.changeset/dull-coats-help.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes-common': patch +--- + +Export `backstage.io/kubernetes-id` and `backstage.io/kubernetes-label-selector` annotations as constants diff --git a/.changeset/itchy-schools-camp.md b/.changeset/itchy-schools-camp.md new file mode 100644 index 0000000000..31b4231a69 --- /dev/null +++ b/.changeset/itchy-schools-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Explicitly stringify extra logger fields with `JSON.stringify` to prevent `[object Object]` errors. diff --git a/.changeset/ninety-teachers-tease.md b/.changeset/ninety-teachers-tease.md index 5cc34b6503..78a890c3d5 100644 --- a/.changeset/ninety-teachers-tease.md +++ b/.changeset/ninety-teachers-tease.md @@ -2,12 +2,12 @@ '@backstage/plugin-catalog-react': minor --- -Introduces a new `EntityCardLayoutBlueprint` that creates custom entity content layouts. +Introduces a new `EntityContentLayoutBlueprint` that creates custom entity content layouts. The layout components receive card elements and can render them as they see fit. Cards is an array of objects with the following properties: - element: `JSx.Element`; -- area: `"peek" | "info" | "full" | undefined`; +- type: `"peek" | "info" | "full" | undefined`; ### Usage example @@ -15,12 +15,12 @@ Creating a custom overview tab layout: ```tsx import { - EntityCardLayoutProps, - EntityCardLayoutBlueprint, + EntityContentLayoutProps, + EntityContentLayoutBlueprint, } from '@backstage/plugin-catalog-react/alpha'; // ... -function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { +function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { const { cards } = props; const classes = useStyles(); return ( @@ -33,7 +33,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { > {cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} @@ -44,14 +44,14 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { {cards - .filter(card => card.area === 'peek') + .filter(card => card.type === 'peek') .map((card, index) => ( {card.element} ))} {cards - .filter(card => !card.area || card.area === 'full') + .filter(card => !card.type || card.type === 'full') .map((card, index) => ( {card.element} @@ -66,7 +66,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { export const customEntityContentOverviewStickyLayoutModule = createFrontendModule({ pluginId: 'app', extensions: [ - EntityCardLayoutBlueprint.make({ + EntityContentLayoutBlueprint.make({ name: 'sticky', params: { // (optional) defaults the `() => false` filter function @@ -83,7 +83,7 @@ Disabling the custom layout: # app-config.yaml app: extensions: - - entity-card-layout:app/sticky: false + - entity-content-layout:app/sticky: false ``` Overriding the custom layout filter: @@ -92,7 +92,7 @@ Overriding the custom layout filter: # app-config.yaml app: extensions: - - entity-card-layout:app/sticky: + - entity-content-layout:app/sticky: config: # This layout will be used only with component entities filter: 'kind:component' diff --git a/.changeset/selfish-cheetahs-sip.md b/.changeset/selfish-cheetahs-sip.md index 590ad59d05..8ebd6b50b3 100644 --- a/.changeset/selfish-cheetahs-sip.md +++ b/.changeset/selfish-cheetahs-sip.md @@ -2,34 +2,34 @@ '@backstage/plugin-catalog-react': minor --- -Add an optional `area` parameter to `EntityCard` extensions. A card's area value determines where it should be rendered by the entity content layout, as well as its maximum size. +Add an optional `type` parameter to `EntityCard` extensions. A card's type determines characteristics such as its expected size and where it will be rendered by the entity content layout. -We are initially supporting only three areas: +Initially the following three types are supported: -- `peek`: used for cards containing infrastucture information (e.g. last builds, deployments, etc.). -- `info`: used for cards that contain entity metadata (e.g. about, links); -- `full`: Contains information that plugins add to an entity (e.g. PagerDuty incidents and on-call escalation). +- `peek`: small vertical cards that provide information at a glance, for example recent builds, deployments, and service health. +- `info`: medium size cards with high priority and frequently used information such as common actions, entity metadata, and links. +- `full`: Large cards that are more feature rich with more information, typically used by plugins that don't quite need the full content view and want to show a card instead. ### Usage examples -Defining a default area when creating a card: +Defining a default type when creating a card: ```diff const myCard = EntityCardBlueprint.make({ name: 'myCard', params: { -+ defaultArea: 'info', ++ type: 'info', loader: import('./MyCard).then(m => { default: m.MyCard }), }, }); ``` -Changing the card area via `app-config.yaml` file: +Changing the card type via `app-config.yaml` file: ```diff app: extensions: + - entity-card:myPlugin/myCard: + config: -+ area: info ++ type: info ``` diff --git a/.changeset/strange-masks-type.md b/.changeset/strange-masks-type.md new file mode 100644 index 0000000000..6b4edf9c5e --- /dev/null +++ b/.changeset/strange-masks-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-react': patch +'@backstage/plugin-techdocs-module-addons-contrib': patch +--- + +Added `TechDocsAddonsBlueprint` extension to allow adding of techdocs addons. diff --git a/.changeset/tall-falcons-juggle.md b/.changeset/tall-falcons-juggle.md new file mode 100644 index 0000000000..6843d48146 --- /dev/null +++ b/.changeset/tall-falcons-juggle.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `ExtensionBoundary.lazyComponent` helper in addition to the existing `ExtensionBoundary.lazy` helper. diff --git a/.changeset/weak-lemons-sing.md b/.changeset/weak-lemons-sing.md new file mode 100644 index 0000000000..e1a349b85e --- /dev/null +++ b/.changeset/weak-lemons-sing.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend': patch +--- + +Improved notifications router tests diff --git a/docs/features/techdocs/addons--new.md b/docs/features/techdocs/addons--new.md new file mode 100644 index 0000000000..07138d4b8e --- /dev/null +++ b/docs/features/techdocs/addons--new.md @@ -0,0 +1,204 @@ +--- +id: addons +title: TechDocs Addons +description: How to find, use, or create TechDocs Addons. +--- + +:::info +This documentation is written for [the new frontend system](../../frontend-system/index.md) which is still in alpha and is only supported by a small number of plugins. +::: + +## Concepts + +TechDocs is a centralized platform for publishing, viewing, and discovering +technical documentation across an entire organization. It's a solid foundation! +But it doesn't solve higher-order documentation needs on its own: how do you +create and reinforce a culture of documentation? How do you build trust in the +quality of technical documentation? + +TechDocs Addons are a mechanism by which you can customize the TechDocs +experience in order to try and address some of these higher-order needs. + +### Addons + +An Addon is just a react component. Like any react component, it can retrieve +and render data using normal Backstage or native hooks, APIs, and components. +Props can be used to configure its behavior, where appropriate. + +### Locations + +Addons declare a `location` where they will be rendered. Most locations are +representative of physical spaces in the TechDocs UI: + +- `Header`: For Addons which fill up the header from the right, on the same + line as the title. +- `Subheader`: For Addons that sit below the header but above all content. + This is a great location for tooling/configuration of TechDocs display. +- `Settings`: These addons are items added to the settings menu list and are designed to make + the reader experience customizable, for example accessibility options. +- `PrimarySidebar`: Left of the content, above of the navigation. +- `SecondarySidebar`: Right of the content, above the table of contents. +- `Content`: A special location intended for Addons which augment the + statically generated content of the documentation itself. +- `Component`: A [proposed-but-not-yet-implemented](https://github.com/backstage/backstage/issues/11109) + virtual location, aimed at simplifying a common type of Addon. + + + +![TechDocs Addon Location Guide](../../assets/techdocs/addon-locations.png) + +### Addon Registry + +The installation and configuration of Addons happens within a Backstage app's +frontend. Addons are imported from plugins and registered as a plugin extension which +are configured for both the TechDocs Reader page as well as the Entity docs page. + +Addons are rendered in the order in which they are registered. + +## Installing and using Addons + +To start using Addons you need to add the `@backstage/plugin-techdocs-module-addons-contrib` package to your app. You can do that by running this command from the root of your project: `yarn --cwd packages/app add @backstage/plugin-techdocs-module-addons-contrib` + +Addons can then be installed as a module in your `App.tsx`: + +```tsx +// packages/app/src/App.tsx + +import { createApp } from '@backstage/frontend-defaults'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { techDocsReportIssueAddonModule } from '@backstage/plugin-techdocs-module-addons-contrib/alpha'; + +// ... + +const app = createApp({ + features: [ + // ... + techDocsReportIssueAddonModule, + // ...other techdocs addon modules + ], +}); + +export default app.createRoot(); +``` + +Note that on the entity page, because the Catalog plugin is responsible for the +page header, TechDocs Addons whose location is `Header` will not be rendered. + +## Available Addons + +Addons can, in principle, be provided by any plugin! To make it easier to +discover available Addons, we've compiled a list of them here: + +| Addon | Package/Plugin | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`techDocsExpandableNavigationAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.expandablenavigation) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | +| [`techDocsReportIssueAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | +| [`techDocsTextSizeAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | +| [`techDocsLightBoxAddonModule`](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.lightbox) | `@backstage/plugin-techdocs-module-addons-contrib/alpha` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). | + +Got an Addon to contribute? Feel free to add a row above! + +## Creating an Addon + +The simplest Addons are plain old react components that get rendered in +specific locations within a TechDocs site. To package such a react component as +an Addon, follow these steps: + +1. Write the component in your plugin like any other component +2. Create the addon extension using the `TechDocsAddonBlueprint` +3. Create and export the addon module from your plugin + +```ts +// plugins/your-plugin/src/plugin.ts + +import { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { CatGifComponent } from './addons'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +// ... + +const techDocsCatGifAddon = AddonBlueprint.make({ + name: 'cat-gif', + params: { + name: 'CatGif', + location: TechDocsAddonLocations.Header, + component: CatGifComponent, + }, +}); + +export const techDocsCatGifAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsCatGifAddon], +}); +``` + +### Addons in the Content location + +Beyond the "render a component in a region" use-case, it's also possible for +Addons to access and manipulate a TechDocs site's DOM; this could be used to, +for example, load and instantiate client-side diagramming libraries, replace +elements with dynamically loaded content, etc. + +This type of Addon is still expressed as a react component, but instead of +returning a react element to be rendered, it updates the DOM via side-effects +(e.g. with `useEffect`). Access to the DOM is made available via utility hooks +provided by the Addon framework. + +```tsx +// plugins/your-plugin/src/addons/MakeAllImagesCatGifs.tsx + +import React, { useEffect } from 'react'; +import { useShadowRootElements } from '@backstage/plugin-techdocs-react'; + +// This is a normal react component; in order to make it an Addon, you would +// still create and provide it via your plugin as described above. The only +// difference is that you'd set `location` to `TechDocsAddonLocations.Content`. +export const MakeAllImagesCatGifsAddon = () => { + // This hook can be used to get references to specific elements. If you need + // access to the whole shadow DOM, use the underlying useShadowRoot() + // hook instead. + const images = useShadowRootElements(['img']); + + useEffect(() => { + images.forEach(img => { + if (img.src !== 'https://example.com/cat.gif') { + img.src = 'https://example.com/cat.gif'; + } + }); + }, [images]); + + // Nothing to render directly, so we can just return null. + return null; +}; +``` + +### Testing Addons + +Install `@backstage/plugin-techdocs-addons-test-utils` as a `devDependency` in +your plugin for access to utilities that make testing such Addons easier. + +A test for the above Addon might look something like this: + +```tsx +// plugins/your-plugin/src/addons/MakeAllImagesCatGifs.test.tsx +import { TechDocsAddonTester } from '@backstage/plugin-techdocs-addons-test-utils'; + +// Note: import your actual addon (the one provided by your plugin). +import { MakeAllImagesCatGifs } from '../plugin.ts'; + +describe('MakeAllImagesCatGifs', () => { + it('replaces img srcs with cat gif', async () => { + const { getByTestId } = await TechDocsAddonTester.buildAddonsInTechDocs([ + , + ]) + .withDom() + .renderWithEffects(); + + expect(getByTestId('fixture')).toHaveAttribute( + 'src', + 'https://example.com/cat.gif', + ); + }); +}); +``` diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md index fff1f09d71..a34c2f1162 100644 --- a/docs/features/techdocs/addons.md +++ b/docs/features/techdocs/addons.md @@ -4,6 +4,10 @@ title: TechDocs Addons description: How to find, use, or create TechDocs Addons. --- +:::info +This documentation is written for [the old frontend system](./getting-started.md#adding-techdocs-frontend-plugin). If you are on the [new frontend system](../../frontend-system/index.md) you may want to read [its own article](./addons--new.md) instead. +::: + ## Concepts TechDocs is a centralized platform for publishing, viewing, and discovering diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index c814d8b8a7..45f11ae1e2 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -28,12 +28,12 @@ app: # Entity page cards - entity-card:catalog/about: config: - area: info + type: info - entity-card:catalog/labels - entity-card:catalog/links: config: filter: kind:component has:links - area: info + type: info # - entity-card:linguist/languages - entity-card:catalog-graph/relations: config: @@ -71,7 +71,7 @@ app: # - entity-content:azure-devops/pull-requests # - entity-content:azure-devops/git-tags - - entity-card-layout:app/sticky: + - entity-content-layout:app/sticky: config: # this layout will apply to entities of kind component filter: 'kind:component' diff --git a/packages/app-next/src/EntityPages.tsx b/packages/app-next/src/EntityPages.tsx index 13c6895187..fd925e68b9 100644 --- a/packages/app-next/src/EntityPages.tsx +++ b/packages/app-next/src/EntityPages.tsx @@ -18,8 +18,8 @@ import React from 'react'; import Grid from '@material-ui/core/Grid'; import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { - EntityCardLayoutBlueprint, - EntityCardLayoutProps, + EntityContentLayoutBlueprint, + EntityContentLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; import { makeStyles } from '@material-ui/core/styles'; @@ -38,7 +38,7 @@ const useStyles = makeStyles(theme => ({ }, })); -function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { +function StickyEntityContentOverviewLayout(props: EntityContentLayoutProps) { const { cards } = props; const classes = useStyles(); return ( @@ -56,7 +56,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { > {cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} @@ -67,14 +67,14 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { {cards - .filter(card => card.area === 'peek') + .filter(card => card.type === 'peek') .map((card, index) => ( {card.element} ))} {cards - .filter(card => !card.area || card.area === 'full') + .filter(card => !card.type || card.type === 'full') .map((card, index) => ( {card.element} @@ -89,7 +89,7 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) { export const customEntityContentOverviewLayoutModule = createFrontendModule({ pluginId: 'app', extensions: [ - EntityCardLayoutBlueprint.make({ + EntityContentLayoutBlueprint.make({ name: 'sticky', params: { loader: async () => StickyEntityContentOverviewLayout, diff --git a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts new file mode 100644 index 0000000000..a4f5c96835 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2024 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 { + ServiceFactoryTester, + mockServices, +} from '@backstage/backend-test-utils'; +import { auditorServiceFactory } from './auditorServiceFactory'; + +describe('auditorServiceFactory', () => { + it('should log with the appropriate log level', async () => { + const mockLogger = mockServices.logger.mock(); + mockLogger.child.mockReturnValue(mockLogger); + + const auditor = await ServiceFactoryTester.from(auditorServiceFactory, { + dependencies: [mockLogger.factory], + }).getSubject(); + + await auditor.createEvent({ + eventId: 'test1', + severityLevel: 'low', + }); + await auditor.createEvent({ + eventId: 'test2', + }); + await auditor.createEvent({ + eventId: 'test3', + severityLevel: 'medium', + }); + + expect(mockLogger.debug).toHaveBeenCalledWith('test.test1', { + eventId: 'test1', + severityLevel: 'low', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + expect(mockLogger.debug).toHaveBeenCalledWith('test.test2', { + eventId: 'test2', + severityLevel: 'low', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + expect(mockLogger.info).toHaveBeenCalledWith('test.test3', { + eventId: 'test3', + severityLevel: 'medium', + actor: { + actorId: 'plugin:test', + }, + plugin: 'test', + status: 'initiated', + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts index 1323ac5d76..6d26bbf258 100644 --- a/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/auditor/auditorServiceFactory.ts @@ -40,7 +40,14 @@ export const auditorServiceFactory = createServiceFactory({ factory({ logger, plugin, auth, httpAuth }) { const auditLogger = logger.child({ isAuditEvent: true }); return DefaultAuditorService.create( - event => auditLogger.info(`${event.plugin}.${event.eventId}`, event), + event => { + const message = `${event.plugin}.${event.eventId}`; + if (event.severityLevel === 'low') { + auditLogger.debug(message, event); + } else { + auditLogger.info(message, event); + } + }, { plugin, auth, httpAuth }, ); }, diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts index ec46f22759..39293dcc55 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.test.ts @@ -94,27 +94,20 @@ describe('WinstonLogger', () => { ); }); - it('gracefully handles fields that are not castable to a string', () => { - const mockTransport = new Transport({ - log: jest.fn(), - logv: jest.fn(), - }); + it('gracefully handles fields that contain deeper object structures', () => { + const log = jest.fn(); + const mockTransport = new Transport({ log }); const logger = WinstonLogger.create({ transports: [mockTransport], }); logger.error('something went wrong', { - field: Object.create(null), + field: { foo: { bar: { baz: 'qux' } } }, }); - expect(mockTransport.log).toHaveBeenCalledWith( - expect.objectContaining({ - [MESSAGE]: expect.stringContaining( - '[field value not castable to string]', - ), - }), - expect.any(Function), + expect(log.mock.calls[0][0][MESSAGE]).toContain( + `={"foo":{"bar":{"baz":"qux"}}}`, ); }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts index 9f7532e66b..545c9377b2 100644 --- a/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts +++ b/packages/backend-defaults/src/entrypoints/rootLogger/WinstonLogger.ts @@ -151,7 +151,7 @@ export class WinstonLogger implements RootLoggerService { let stringValue = ''; try { - stringValue = `${value}`; + stringValue = JSON.stringify(value); } catch (e) { stringValue = '[field value not castable to string]'; } diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 13eb751361..5de595b6a9 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -964,8 +964,13 @@ export namespace ExtensionBoundary { // (undocumented) export function lazy( appNode: AppNode, - lazyElement: () => Promise, + loader: () => Promise, ): JSX.Element; + // (undocumented) + export function lazyComponent( + appNode: AppNode, + loader: () => Promise<(props: TProps) => JSX.Element>, + ): (props: TProps) => JSX.Element; } // @public (undocumented) diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 176dfd4c3c..1737e0b40b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -96,10 +96,10 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { export namespace ExtensionBoundary { export function lazy( appNode: AppNode, - lazyElement: () => Promise, + loader: () => Promise, ): JSX.Element { const ExtensionComponent = reactLazy(() => - lazyElement().then(element => ({ default: () => element })), + loader().then(element => ({ default: () => element })), ); return ( @@ -107,4 +107,19 @@ export namespace ExtensionBoundary { ); } + + export function lazyComponent( + appNode: AppNode, + loader: () => Promise<(props: TProps) => JSX.Element>, + ): (props: TProps) => JSX.Element { + const ExtensionComponent = reactLazy(() => + loader().then(Component => ({ default: Component })), + ) as unknown as React.ComponentType; + + return (props: TProps) => ( + + + + ); + } } diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 32b00d741f..3c475e597e 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -8,6 +8,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -108,11 +109,11 @@ const _default: FrontendPlugin< name: 'has-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -135,8 +136,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -145,7 +146,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/definition': ExtensionDefinition<{ @@ -153,11 +154,11 @@ const _default: FrontendPlugin< name: 'definition'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -180,8 +181,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -190,7 +191,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/consumed-apis': ExtensionDefinition<{ @@ -198,11 +199,11 @@ const _default: FrontendPlugin< name: 'consumed-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -225,8 +226,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -235,7 +236,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/provided-apis': ExtensionDefinition<{ @@ -243,11 +244,11 @@ const _default: FrontendPlugin< name: 'provided-apis'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -270,8 +271,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -280,7 +281,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/consuming-components': ExtensionDefinition<{ @@ -288,11 +289,11 @@ const _default: FrontendPlugin< name: 'consuming-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -315,8 +316,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -325,7 +326,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:api-docs/providing-components': ExtensionDefinition<{ @@ -333,11 +334,11 @@ const _default: FrontendPlugin< name: 'providing-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -360,8 +361,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -370,7 +371,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-content:api-docs/definition': ExtensionDefinition<{ @@ -422,7 +423,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -434,6 +435,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' @@ -492,7 +494,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -504,6 +506,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts index 1021862c12..985146229a 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.test.ts @@ -363,4 +363,77 @@ describe('performStitching', () => { ); }, ); + + it.each(databases.eachSupportedId())( + 'replaces existing stitch ticket %p', + async databaseId => { + const knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + + await knex('refresh_state').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + unprocessed_entity: JSON.stringify({}), + processed_entity: JSON.stringify({ + apiVersion: 'a', + kind: 'k', + metadata: { + name: 'n', + namespace: 'ns', + }, + spec: { + k: 'v', + }, + }), + errors: '[]', + next_update_at: knex.fn.now(), + last_discovery_at: knex.fn.now(), + }, + ]); + await knex('final_entities').insert([ + { + entity_id: 'my-id', + entity_ref: 'k:ns/n', + hash: '', + stitch_ticket: 'old-ticket', + final_entity: JSON.stringify({}), + }, + ]); + + await expect( + knex('final_entities').select([ + 'entity_id', + 'stitch_ticket', + ]), + ).resolves.toEqual([ + { + entity_id: 'my-id', + stitch_ticket: expect.stringContaining('old-ticket'), + }, + ]); + + const stitchLogger = mockServices.logger.mock(); + await expect( + performStitching({ + knex, + logger: stitchLogger, + strategy: { mode: 'immediate' }, + entityRef: 'k:ns/n', + }), + ).resolves.toBe('changed'); + + await expect( + knex('final_entities').select([ + 'entity_id', + 'stitch_ticket', + ]), + ).resolves.toEqual([ + { + entity_id: 'my-id', + stitch_ticket: expect.not.stringContaining('old-ticket'), + }, + ]); + }, + ); }); diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index a7612ec0f5..9cdd5c3f01 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -8,6 +8,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Direction } from '@backstage/plugin-catalog-graph'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -43,7 +44,7 @@ const _default: FrontendPlugin< height: number | undefined; } & { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { height?: number | undefined; @@ -59,7 +60,7 @@ const _default: FrontendPlugin< relationPairs?: [string, string][] | undefined; } & { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -82,8 +83,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -102,7 +103,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'page:catalog-graph': ExtensionDefinition<{ diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 22806998fd..02fac5f75a 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -99,9 +99,6 @@ export function convertLegacyEntityContentExtension( }, ): ExtensionDefinition; -// @alpha -export const defaultEntityCardAreas: readonly ['peek', 'info', 'full']; - // @alpha export const defaultEntityContentGroups: { documentation: string; @@ -117,7 +114,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; output: | ConfigurableExtensionDataRef @@ -136,8 +133,8 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -145,11 +142,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ inputs: {}; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -162,80 +159,16 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ 'catalog.entity-filter-expression', {} >; - area: ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + type: ConfigurableExtensionDataRef< + EntityCardType, + 'catalog.entity-card-type', {} >; }; }>; // @alpha (undocumented) -export const EntityCardLayoutBlueprint: ExtensionBlueprint<{ - kind: 'entity-card-layout'; - name: undefined; - params: { - defaultFilter?: string | ((entity: Entity) => boolean) | undefined; - loader: () => Promise< - (props: EntityCardLayoutProps) => React_2.JSX.Element - >; - }; - output: - | ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.entity-filter-function', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - string, - 'catalog.entity-filter-expression', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => React_2.JSX.Element, - 'catalog.entity-card-layout.component', - {} - >; - inputs: {}; - config: { - area: string | undefined; - filter: string | undefined; - }; - configInput: { - filter?: string | undefined; - area?: string | undefined; - }; - dataRefs: { - filterFunction: ConfigurableExtensionDataRef< - (entity: Entity) => boolean, - 'catalog.entity-filter-function', - {} - >; - filterExpression: ConfigurableExtensionDataRef< - string, - 'catalog.entity-filter-expression', - {} - >; - component: ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => React_2.JSX.Element, - 'catalog.entity-card-layout.component', - {} - >; - }; -}>; - -// @alpha (undocumented) -export interface EntityCardLayoutProps { - // (undocumented) - cards: Array<{ - area?: (typeof defaultEntityCardAreas)[number]; - element: React_2.JSX.Element; - }>; -} +export type EntityCardType = 'peek' | 'info' | 'full'; // @alpha export const EntityContentBlueprint: ExtensionBlueprint<{ @@ -246,6 +179,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' @@ -280,7 +214,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -316,13 +250,80 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ {} >; group: ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', {} >; }; }>; +// @alpha (undocumented) +export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ + kind: 'entity-content-layout'; + name: undefined; + params: { + filter?: string | ((entity: Entity) => boolean) | undefined; + loader: () => Promise< + (props: EntityContentLayoutProps) => React_2.JSX.Element + >; + }; + output: + | ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + (props: EntityContentLayoutProps) => React_2.JSX.Element, + 'catalog.entity-content-layout.component', + {} + >; + inputs: {}; + config: { + type: string | undefined; + filter: string | undefined; + }; + configInput: { + filter?: string | undefined; + type?: string | undefined; + }; + dataRefs: { + filterFunction: ConfigurableExtensionDataRef< + (entity: Entity) => boolean, + 'catalog.entity-filter-function', + {} + >; + filterExpression: ConfigurableExtensionDataRef< + string, + 'catalog.entity-filter-expression', + {} + >; + component: ConfigurableExtensionDataRef< + (props: EntityContentLayoutProps) => React_2.JSX.Element, + 'catalog.entity-content-layout.component', + {} + >; + }; +}>; + +// @alpha (undocumented) +export interface EntityContentLayoutProps { + // (undocumented) + cards: Array<{ + type?: EntityCardType; + element: React_2.JSX.Element; + }>; +} + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index e534db8575..563bb15ebd 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -51,7 +51,10 @@ describe('EntityCardBlueprint', () => { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "properties": { - "area": { + "filter": { + "type": "string", + }, + "type": { "enum": [ "peek", "info", @@ -59,9 +62,6 @@ describe('EntityCardBlueprint', () => { ], "type": "string", }, - "filter": { - "type": "string", - }, }, "type": "object", }, @@ -96,7 +96,7 @@ describe('EntityCardBlueprint', () => { "config": { "optional": true, }, - "id": "catalog.entity-card-area", + "id": "catalog.entity-card-type", "optional": [Function], "toString": [Function], }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 1b2fa4edcc..5ef6106f40 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -22,8 +22,9 @@ import { import { entityFilterFunctionDataRef, entityFilterExpressionDataRef, - entityCardAreaDataRef, - defaultEntityCardAreas, + entityCardTypeDataRef, + entityCardTypes, + EntityCardType, } from './extensionData'; /** @@ -37,30 +38,30 @@ export const EntityCardBlueprint = createExtensionBlueprint({ coreExtensionData.reactElement, entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), - entityCardAreaDataRef.optional(), + entityCardTypeDataRef.optional(), ], dataRefs: { filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, - area: entityCardAreaDataRef, + type: entityCardTypeDataRef, }, config: { schema: { filter: z => z.string().optional(), - area: z => z.enum(defaultEntityCardAreas).optional(), + type: z => z.enum(entityCardTypes).optional(), }, }, *factory( { loader, filter, - defaultArea, + type, }: { loader: () => Promise; filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; - defaultArea?: (typeof defaultEntityCardAreas)[number]; + type?: EntityCardType; }, { node, config }, ) { @@ -74,13 +75,13 @@ export const EntityCardBlueprint = createExtensionBlueprint({ yield entityFilterFunctionDataRef(filter); } - const area = config.area ?? defaultArea; - if (area) { - yield entityCardAreaDataRef(area); + const finalType = config.type ?? type; + if (finalType) { + yield entityCardTypeDataRef(finalType); } else { // eslint-disable-next-line no-console console.warn( - `DEPRECATION WARNING: Not providing defaultArea for entity cards is deprecated. Missing from '${node.spec.id}'`, + `DEPRECATION WARNING: Not providing type for entity cards is deprecated. Missing from '${node.spec.id}'`, ); } }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 5e820e3b0a..aa3608c299 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -70,7 +70,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ loader: () => Promise; defaultPath: string; defaultTitle: string; - defaultGroup?: keyof typeof defaultEntityContentGroups; + defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {}); routeRef?: RouteRef; filter?: | typeof entityFilterFunctionDataRef.T diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx similarity index 62% rename from plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx rename to plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index 3aaba93813..ab15a3d791 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardLauyoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -22,27 +22,27 @@ import { import { entityFilterExpressionDataRef, entityFilterFunctionDataRef, - defaultEntityCardAreas, + EntityCardType, } from './extensionData'; -import React, { lazy as reactLazy, ComponentProps } from 'react'; +import React from 'react'; /** @alpha */ -export interface EntityCardLayoutProps { +export interface EntityContentLayoutProps { cards: Array<{ - area?: (typeof defaultEntityCardAreas)[number]; + type?: EntityCardType; element: React.JSX.Element; }>; } const entityCardLayoutComponentDataRef = createExtensionDataRef< - (props: EntityCardLayoutProps) => React.JSX.Element + (props: EntityContentLayoutProps) => React.JSX.Element >().with({ - id: 'catalog.entity-card-layout.component', + id: 'catalog.entity-content-layout.component', }); /** @alpha */ -export const EntityCardLayoutBlueprint = createExtensionBlueprint({ - kind: 'entity-card-layout', +export const EntityContentLayoutBlueprint = createExtensionBlueprint({ + kind: 'entity-content-layout', attachTo: { id: 'entity-content:catalog/overview', input: 'layouts' }, output: [ entityFilterFunctionDataRef.optional(), @@ -56,44 +56,34 @@ export const EntityCardLayoutBlueprint = createExtensionBlueprint({ }, config: { schema: { - area: z => z.string().optional(), + type: z => z.string().optional(), filter: z => z.string().optional(), }, }, *factory( { loader, - defaultFilter, + filter, }: { - defaultFilter?: + filter?: | typeof entityFilterFunctionDataRef.T | typeof entityFilterExpressionDataRef.T; loader: () => Promise< - (props: EntityCardLayoutProps) => React.JSX.Element + (props: EntityContentLayoutProps) => React.JSX.Element >; }, { node, config }, ) { if (config.filter) { yield entityFilterExpressionDataRef(config.filter); - } else if (typeof defaultFilter === 'string') { - yield entityFilterExpressionDataRef(defaultFilter); - } else if (typeof defaultFilter === 'function') { - yield entityFilterFunctionDataRef(defaultFilter); + } else if (typeof filter === 'string') { + yield entityFilterExpressionDataRef(filter); + } else if (typeof filter === 'function') { + yield entityFilterFunctionDataRef(filter); } - const ExtensionComponent = reactLazy(() => - loader().then(component => ({ default: component })), - ); - yield entityCardLayoutComponentDataRef( - (props: ComponentProps) => { - return ( - - - - ); - }, + ExtensionBoundary.lazyComponent(node, loader), ); }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index b76e970dfb..bae7d4e668 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -45,21 +45,25 @@ export const defaultEntityContentGroups = { }; /** @internal */ -export const entityContentGroupDataRef = createExtensionDataRef< - false | string ->().with({ +export const entityContentGroupDataRef = createExtensionDataRef().with({ id: 'catalog.entity-content-group', }); /** - * @alpha - * Default entity content groups. + * @internal + * Available entity card types */ -export const defaultEntityCardAreas = ['peek', 'info', 'full'] as const; +export const entityCardTypes = [ + 'peek', + 'info', + 'full', +] as const satisfies readonly EntityCardType[]; + +/** @alpha */ +export type EntityCardType = 'peek' | 'info' | 'full'; /** @internal */ -export const entityCardAreaDataRef = createExtensionDataRef< - (typeof defaultEntityCardAreas)[number] ->().with({ - id: 'catalog.entity-card-area', -}); +export const entityCardTypeDataRef = + createExtensionDataRef().with({ + id: 'catalog.entity-card-type', + }); diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index bda94c8d04..9cde440819 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -16,10 +16,8 @@ export { EntityCardBlueprint } from './EntityCardBlueprint'; export { EntityContentBlueprint } from './EntityContentBlueprint'; export { - EntityCardLayoutBlueprint, - type EntityCardLayoutProps, -} from './EntityCardLauyoutBlueprint'; -export { - defaultEntityContentGroups, - defaultEntityCardAreas, -} from './extensionData'; + EntityContentLayoutBlueprint, + type EntityContentLayoutProps, +} from './EntityContentLayoutBlueprint'; +export { defaultEntityContentGroups } from './extensionData'; +export type { EntityCardType } from './extensionData'; diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index c78595b836..53f1f751ef 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -10,7 +10,8 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityCardLayoutProps } from '@backstage/plugin-catalog-react/alpha'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; +import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; @@ -218,11 +219,11 @@ const _default: FrontendPlugin< name: 'about'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -241,8 +242,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -251,7 +252,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/links': ExtensionDefinition<{ @@ -259,11 +260,11 @@ const _default: FrontendPlugin< name: 'links'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -282,8 +283,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -292,7 +293,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/labels': ExtensionDefinition<{ @@ -300,11 +301,11 @@ const _default: FrontendPlugin< name: 'labels'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -323,8 +324,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -333,7 +334,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/depends-on-components': ExtensionDefinition<{ @@ -341,11 +342,11 @@ const _default: FrontendPlugin< name: 'depends-on-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -364,8 +365,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -374,7 +375,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/depends-on-resources': ExtensionDefinition<{ @@ -382,11 +383,11 @@ const _default: FrontendPlugin< name: 'depends-on-resources'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -405,8 +406,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -415,7 +416,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-components': ExtensionDefinition<{ @@ -423,11 +424,11 @@ const _default: FrontendPlugin< name: 'has-components'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -446,8 +447,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -456,7 +457,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-resources': ExtensionDefinition<{ @@ -464,11 +465,11 @@ const _default: FrontendPlugin< name: 'has-resources'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -487,8 +488,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -497,7 +498,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-subcomponents': ExtensionDefinition<{ @@ -505,11 +506,11 @@ const _default: FrontendPlugin< name: 'has-subcomponents'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -528,8 +529,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -538,7 +539,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-subdomains': ExtensionDefinition<{ @@ -546,11 +547,11 @@ const _default: FrontendPlugin< name: 'has-subdomains'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -569,8 +570,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -579,7 +580,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:catalog/has-systems': ExtensionDefinition<{ @@ -587,11 +588,11 @@ const _default: FrontendPlugin< name: 'has-systems'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef @@ -610,8 +611,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -620,7 +621,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-content:catalog/overview': ExtensionDefinition<{ @@ -666,7 +667,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -689,8 +690,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - (props: EntityCardLayoutProps) => JSX_2.Element, - 'catalog.entity-card-layout.component', + (props: EntityContentLayoutProps) => JSX_2.Element, + 'catalog.entity-content-layout.component', {} >, { @@ -715,8 +716,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -734,6 +735,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' @@ -1001,7 +1003,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; diff --git a/plugins/catalog/src/alpha/entityContents.test.tsx b/plugins/catalog/src/alpha/entityContents.test.tsx index 5dfdca9d73..2ea61c1c54 100644 --- a/plugins/catalog/src/alpha/entityContents.test.tsx +++ b/plugins/catalog/src/alpha/entityContents.test.tsx @@ -25,7 +25,7 @@ import { import { catalogOverviewEntityContent } from './entityContents'; import { EntityCardBlueprint, - EntityCardLayoutBlueprint, + EntityContentLayoutBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { @@ -113,7 +113,7 @@ describe('Overview content', () => { const infoCard = EntityCardBlueprint.make({ name: 'info-card', params: { - defaultArea: 'info', + type: 'info', loader: async () =>
Info card
, }, }); @@ -125,7 +125,7 @@ describe('Overview content', () => { }, }); - const customLayout = EntityCardLayoutBlueprint.make({ + const customLayout = EntityContentLayoutBlueprint.make({ name: 'custom-layout', params: { loader: @@ -136,14 +136,14 @@ describe('Overview content', () => {

Custom layout

{cards - .filter(card => card.area === 'info') + .filter(card => card.type === 'info') .map((card, index) => ( {card.element} ))}
{cards - .filter(card => card.area !== 'info') + .filter(card => card.type !== 'info') .map((card, index) => ( {card.element} ))} diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index 7ad209ec39..3e883433a9 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -23,8 +23,8 @@ import { import { EntityCardBlueprint, EntityContentBlueprint, - EntityCardLayoutBlueprint, - EntityCardLayoutProps, + EntityContentLayoutBlueprint, + EntityContentLayoutProps, } from '@backstage/plugin-catalog-react/alpha'; import { buildFilterFn } from './filter/FilterWrapper'; import { useEntity } from '@backstage/plugin-catalog-react'; @@ -34,15 +34,15 @@ export const catalogOverviewEntityContent = name: 'overview', inputs: { layouts: createExtensionInput([ - EntityCardLayoutBlueprint.dataRefs.filterFunction.optional(), - EntityCardLayoutBlueprint.dataRefs.filterExpression.optional(), - EntityCardLayoutBlueprint.dataRefs.component, + EntityContentLayoutBlueprint.dataRefs.filterFunction.optional(), + EntityContentLayoutBlueprint.dataRefs.filterExpression.optional(), + EntityContentLayoutBlueprint.dataRefs.component, ]), cards: createExtensionInput([ coreExtensionData.reactElement, EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), - EntityCardBlueprint.dataRefs.area.optional(), + EntityCardBlueprint.dataRefs.type.optional(), ]), }, factory: (originalFactory, { node, inputs }) => { @@ -56,7 +56,7 @@ export const catalogOverviewEntityContent = })), ); - const DefaultLayoutComponent = (props: EntityCardLayoutProps) => { + const DefaultLayoutComponent = (props: EntityContentLayoutProps) => { return ( @@ -67,11 +67,15 @@ export const catalogOverviewEntityContent = const layouts = [ ...inputs.layouts.map(layout => ({ filter: buildFilterFn( - layout.get(EntityCardLayoutBlueprint.dataRefs.filterFunction), - layout.get(EntityCardLayoutBlueprint.dataRefs.filterExpression), + layout.get( + EntityContentLayoutBlueprint.dataRefs.filterFunction, + ), + layout.get( + EntityContentLayoutBlueprint.dataRefs.filterExpression, + ), ), Component: layout.get( - EntityCardLayoutBlueprint.dataRefs.component, + EntityContentLayoutBlueprint.dataRefs.component, ), })), { @@ -82,7 +86,7 @@ export const catalogOverviewEntityContent = const cards = inputs.cards.map(card => ({ element: card.get(coreExtensionData.reactElement), - area: card.get(EntityCardBlueprint.dataRefs.area), + type: card.get(EntityCardBlueprint.dataRefs.type), filter: buildFilterFn( card.get(EntityContentBlueprint.dataRefs.filterFunction), card.get(EntityContentBlueprint.dataRefs.filterExpression), diff --git a/plugins/kubernetes-backend/report.api.md b/plugins/kubernetes-backend/report.api.md index 281c8e5cac..345c53f65b 100644 --- a/plugins/kubernetes-backend/report.api.md +++ b/plugins/kubernetes-backend/report.api.md @@ -99,7 +99,7 @@ export type CustomResource = k8sAuthTypes.CustomResource; export type CustomResourcesByEntity = k8sAuthTypes.CustomResourcesByEntity; // @public (undocumented) -export const DEFAULT_OBJECTS: ObjectToFetch[]; +export const DEFAULT_OBJECTS: ObjectToFetch_2[]; // @public export class DispatchStrategy implements AuthenticationStrategy_2 { diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 74be477578..424212287f 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -16,13 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { - CustomResource, - FetchResponseWrapper, - KubernetesFetcher, KubernetesObjectsProviderOptions, - KubernetesServiceLocator, ObjectsByEntityRequest, - ObjectToFetch, } from '../types/types'; import { ClientContainerStatus, @@ -31,6 +26,8 @@ import { ClusterObjects, CustomResourceMatcher, FetchResponse, + KUBERNETES_ANNOTATION, + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION, KubernetesRequestAuth, ObjectsByEntityResponse, PodFetchResponse, @@ -44,10 +41,15 @@ import { import { AuthenticationStrategy, ClusterDetails, + CustomResource, CustomResourcesByEntity, + FetchResponseWrapper, KubernetesCredential, + KubernetesFetcher, KubernetesObjectsByEntity, KubernetesObjectsProvider, + KubernetesServiceLocator, + ObjectToFetch, } from '@backstage/plugin-kubernetes-node'; import { BackstageCredentials, @@ -285,8 +287,8 @@ export class KubernetesFanOutHandler implements KubernetesObjectsProvider { const labelSelector: string = entity.metadata?.annotations?.[ - 'backstage.io/kubernetes-label-selector' - ] || `backstage.io/kubernetes-id=${entityName}`; + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION + ] || `${KUBERNETES_ANNOTATION}=${entityName}`; const namespace = entity.metadata?.annotations?.['backstage.io/kubernetes-namespace']; diff --git a/plugins/kubernetes-common/report.api.md b/plugins/kubernetes-common/report.api.md index cc99e5063d..80486259a2 100644 --- a/plugins/kubernetes-common/report.api.md +++ b/plugins/kubernetes-common/report.api.md @@ -317,6 +317,13 @@ export interface JobsFetchResponse { type: 'jobs'; } +// @public +export const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; + +// @public +export const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = + 'backstage.io/kubernetes-label-selector'; + // @public export const kubernetesClustersReadPermission: BasicPermission; diff --git a/plugins/kubernetes-common/src/catalog-entity-constants.ts b/plugins/kubernetes-common/src/catalog-entity-constants.ts index 996faf670d..472e19ef7a 100644 --- a/plugins/kubernetes-common/src/catalog-entity-constants.ts +++ b/plugins/kubernetes-common/src/catalog-entity-constants.ts @@ -14,6 +14,29 @@ * limitations under the License. */ +/** + * The annotation key used to identify Kubernetes resources in Backstage. + * This constant represents the standard annotation 'backstage.io/kubernetes-id' + * which links catalog entities to their corresponding Kubernetes resources. + * + * @public + */ +export const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; + +/** + * Annotation used to specify a Kubernetes label selector query for filtering resources. + * When this annotation is added to a catalog entity, it defines criteria for selecting + * Kubernetes resources based on their labels. + * + * @public + * + * @remarks + * The value of this annotation should be a valid Kubernetes label selector query string. + * For example: 'app=my-app,environment=production' + */ +export const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = + 'backstage.io/kubernetes-label-selector'; + /** * Annotation for specifying the API server of a Kubernetes cluster * diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 3544768dfc..ea8b015dcc 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -108,7 +108,7 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; @@ -120,6 +120,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/kubernetes/src/Router.tsx b/plugins/kubernetes/src/Router.tsx index db54d6c513..c613de92c3 100644 --- a/plugins/kubernetes/src/Router.tsx +++ b/plugins/kubernetes/src/Router.tsx @@ -23,10 +23,10 @@ import { import { Route, Routes } from 'react-router-dom'; import { KubernetesContent } from './KubernetesContent'; import Button from '@material-ui/core/Button'; - -const KUBERNETES_ANNOTATION = 'backstage.io/kubernetes-id'; -const KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION = - 'backstage.io/kubernetes-label-selector'; +import { + KUBERNETES_ANNOTATION, + KUBERNETES_LABEL_SELECTOR_QUERY_ANNOTATION, +} from '@backstage/plugin-kubernetes-common'; export const isKubernetesAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[KUBERNETES_ANNOTATION]) || diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index e557e5818d..46ee00c554 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -19,53 +19,99 @@ import request from 'supertest'; import { createRouter } from './router'; import { SignalsService } from '@backstage/plugin-signals-node'; import { - TestDatabases, mockCredentials, mockErrorHandler, mockServices, + TestDatabaseId, + TestDatabases, } from '@backstage/backend-test-utils'; import { NotificationSendOptions } from '@backstage/plugin-notifications-node'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { DatabaseService } from '@backstage/backend-plugin-api'; +import { v4 as uuid } from 'uuid'; -describe('createRouter', () => { - const databases = TestDatabases.create(); +const databases = TestDatabases.create(); +async function createDatabase( + databaseId: TestDatabaseId, +): Promise { + const knex = await databases.init(databaseId); + return mockServices.database({ knex, migrations: { skip: false } }); +} + +describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { let app: express.Express; + let database: DatabaseService; const signalService: jest.Mocked = { publish: jest.fn(), }; const userInfo = mockServices.userInfo(); - const httpAuth = mockServices.httpAuth({ - defaultCredentials: mockCredentials.service(), - }); + const auth = mockServices.auth(); const config = mockServices.rootConfig({ data: { app: { baseUrl: 'http://localhost' } }, }); - const catalog = catalogServiceMock(); - beforeAll(async () => { - const knex = await databases.init('SQLITE_3'); - const router = await createRouter({ - logger: mockServices.logger.mock(), - database: { getClient: async () => knex }, - signals: signalService, - userInfo, - config, - httpAuth, - auth, - catalog, - }); - app = express().use(router).use(mockErrorHandler()); + const catalog = catalogServiceMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'mock', + namespace: 'default', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'mock', + namespace: 'default', + }, + relations: [ + { + type: 'hasMember', + targetRef: 'user:default/mock', + }, + ], + }, + ], }); - beforeEach(() => { - jest.resetAllMocks(); + beforeAll(async () => { + database = await createDatabase(databaseId); }); describe('POST /notifications', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.service(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + await client('user_settings').del(); + }); + const sendNotification = async (data: NotificationSendOptions) => request(app) .post('/notifications') @@ -84,7 +130,6 @@ describe('createRouter', () => { link: 'javascript:alert(document.domain)', }, }); - expect(javascriptXSS.status).toEqual(400); const ftpLink = await sendNotification({ @@ -124,6 +169,19 @@ describe('createRouter', () => { }); expect(httpsLink.status).toEqual(200); + expect(httpsLink.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + link: 'https://example.com', + }, + user: null, + }, + ]); }); it('should accept relative links', async () => { @@ -138,6 +196,385 @@ describe('createRouter', () => { }); expect(catalogLink.status).toEqual(200); + expect(catalogLink.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + link: '/catalog', + }, + user: null, + }, + ]); + }); + + it('should send to user entity', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should send to group entity', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['group:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should only send one notification per user', async () => { + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['group:default/mock', 'user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + severity: 'normal', + title: 'test notification', + }, + user: 'user:default/mock', + }, + ]); + + const client = await database.getClient(); + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(1); + }); + + it('should not send to user entity if disabled in settings', async () => { + const client = await database.getClient(); + await client('user_settings').insert({ + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + enabled: false, + }); + + const response = await sendNotification({ + recipients: { + type: 'entity', + entityRef: ['user:default/mock'], + }, + payload: { + title: 'test notification', + }, + }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + + const notifications = await client('notification') + .where('user', 'user:default/mock') + .select(); + expect(notifications).toHaveLength(0); + }); + + it('should fail without recipients', async () => { + const response = await sendNotification({ + payload: { + title: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + + it('should fail with invalid recipients', async () => { + const response = await sendNotification({ + recipients: { + type: 'invalid', + }, + payload: { + title: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + + it('should fail without title', async () => { + const response = await sendNotification({ + recipients: { + type: 'broadcast', + }, + payload: { + description: 'test notification', + }, + } as unknown as NotificationSendOptions); + + expect(response.status).toEqual(400); + }); + }); + + describe('GET /', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('notification').del(); + await client('broadcast').del(); + }); + + it('should return notifications', async () => { + const client = await database.getClient(); + await client('broadcast').insert({ + id: uuid(), + origin: 'external:test-service', + title: 'Test broadcast notification', + created: new Date(), + severity: 'high', + }); + await client('notification').insert({ + id: uuid(), + user: 'user:default/mock', + origin: 'external:test-service', + title: 'Test notification', + created: new Date(), + severity: 'normal', + }); + + const response = await request(app).get('/'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notifications: [ + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + description: null, + icon: null, + link: null, + scope: null, + severity: 'normal', + title: 'Test notification', + topic: null, + }, + read: null, + saved: null, + updated: null, + user: 'user:default/mock', + }, + { + created: expect.any(String), + id: expect.any(String), + origin: 'external:test-service', + payload: { + description: null, + icon: null, + link: null, + scope: null, + severity: 'high', + title: 'Test broadcast notification', + topic: null, + }, + read: null, + saved: null, + updated: null, + user: null, + }, + ], + totalCount: 2, + }); + }); + }); + + describe('GET /settings', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('user_settings').del(); + }); + + it('should return user settings', async () => { + const client = await database.getClient(); + await client('user_settings').insert({ + user: 'user:default/mock', + channel: 'Web', + origin: 'external:test-service', + enabled: false, + }); + + const response = await request(app).get('/settings'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + channels: [ + { + id: 'Web', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }); + }); + }); + + describe('POST /settings', () => { + const httpAuth = mockServices.httpAuth({ + defaultCredentials: mockCredentials.user(), + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: mockServices.logger.mock(), + database, + signals: signalService, + userInfo, + config, + httpAuth, + auth, + catalog, + }); + app = express().use(router).use(mockErrorHandler()); + }); + + beforeEach(async () => { + jest.resetAllMocks(); + const client = await database.getClient(); + await client('user_settings').del(); + }); + + it('should save user settings', async () => { + await request(app) + .post('/settings') + .send({ + channels: [ + { + id: 'Web', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + + const client = await database.getClient(); + const settings = await client('user_settings').select(); + expect(settings.length).toEqual(1); + expect(settings[0].user).toEqual('user:default/mock'); + expect(settings[0].channel).toEqual('Web'); + expect(settings[0].origin).toEqual('external:test-service'); + expect(Boolean(settings[0].enabled)).toEqual(false); + }); + + it('should fail to save user settings with invalid channel', async () => { + const response = await request(app) + .post('/settings') + .send({ + channels: [ + { + id: 'Invalid', + origins: [{ enabled: false, id: 'external:test-service' }], + }, + ], + }) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json'); + expect(response.status).toEqual(400); + + const client = await database.getClient(); + const settings = await client('user_settings').select(); + expect(settings.length).toEqual(0); }); }); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 06a0a5ebb0..368974b6ca 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -180,7 +180,7 @@ export async function createRouter( const processOptions = async ( opts: NotificationSendOptions, origin: string, - ) => { + ): Promise => { const filtered = await filterProcessors({ ...opts, origin, user: null }); let ret = opts; for (const processor of filtered) { @@ -553,8 +553,14 @@ export async function createRouter( let users = []; if (!recipients || !title) { - logger.error(`Invalid notification request received`); - throw new InputError(`Invalid notification request received`); + const missing = [ + !title ? 'title' : null, + !recipients ? 'recipients' : null, + ].filter(Boolean); + const err = `Invalid notification request received: missing ${missing.join( + ', ', + )}`; + throw new InputError(err); } if (link) { @@ -581,7 +587,7 @@ export async function createRouter( origin, ); notifications.push(broadcast); - } else { + } else if (recipients.type === 'entity') { const entityRef = recipients.entityRef; try { @@ -591,7 +597,6 @@ export async function createRouter( { auth, catalogClient: catalog }, ); } catch (e) { - logger.error(`Failed to resolve notification receivers: ${e}`); throw new InputError('Failed to resolve notification receivers', e); } @@ -602,6 +607,10 @@ export async function createRouter( origin, ); notifications.push(...userNotifications); + } else { + throw new InputError( + `Invalid recipients type, please use either 'broadcast' or 'entity'`, + ); } res.json(notifications); diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index 74d2fdf68c..eb5c41faa1 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -5,6 +5,7 @@ ```ts import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -22,11 +23,11 @@ const _default: FrontendPlugin< name: 'group-profile'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -49,8 +50,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -59,7 +60,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/members-list': ExtensionDefinition<{ @@ -67,11 +68,11 @@ const _default: FrontendPlugin< name: 'members-list'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -94,8 +95,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -104,7 +105,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/ownership': ExtensionDefinition<{ @@ -112,11 +113,11 @@ const _default: FrontendPlugin< name: 'ownership'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -139,8 +140,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -149,7 +150,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; 'entity-card:org/user-profile': ExtensionDefinition<{ @@ -157,11 +158,11 @@ const _default: FrontendPlugin< name: 'user-profile'; config: { filter: string | undefined; - area: 'full' | 'info' | 'peek' | undefined; + type: 'full' | 'info' | 'peek' | undefined; }; configInput: { filter?: string | undefined; - area?: 'full' | 'info' | 'peek' | undefined; + type?: 'full' | 'info' | 'peek' | undefined; }; output: | ConfigurableExtensionDataRef< @@ -184,8 +185,8 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - 'full' | 'info' | 'peek', - 'catalog.entity-card-area', + EntityCardType, + 'catalog.entity-card-type', { optional: true; } @@ -194,7 +195,7 @@ const _default: FrontendPlugin< params: { loader: () => Promise; filter?: string | ((entity: Entity) => boolean) | undefined; - defaultArea?: 'full' | 'info' | 'peek' | undefined; + type?: EntityCardType | undefined; }; }>; } diff --git a/plugins/scaffolder-backend-module-rails/Rails.dockerfile b/plugins/scaffolder-backend-module-rails/Rails.dockerfile index 8ea3fc370a..e22d433e3e 100644 --- a/plugins/scaffolder-backend-module-rails/Rails.dockerfile +++ b/plugins/scaffolder-backend-module-rails/Rails.dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.3 +FROM ruby:3.4 RUN apt-get update -qq && \ apt-get install -y nodejs postgresql-client git && \ diff --git a/plugins/techdocs-addons-test-utils/src/test-utils.tsx b/plugins/techdocs-addons-test-utils/src/test-utils.tsx index 2066acbb48..1f6337fc23 100644 --- a/plugins/techdocs-addons-test-utils/src/test-utils.tsx +++ b/plugins/techdocs-addons-test-utils/src/test-utils.tsx @@ -38,7 +38,11 @@ import { techdocsStorageApiRef, } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPage, techdocsPlugin } from '@backstage/plugin-techdocs'; -import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { + EntityPresentationApi, + entityPresentationApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; import { searchApiRef } from '@backstage/plugin-search-react'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; @@ -223,8 +227,17 @@ export class TechDocsAddonTester { }), }; + const entityPresentationApi: EntityPresentationApi = { + forEntity: jest.fn().mockReturnValue({ + snapshot: { + primaryTitle: 'Test Entity', + }, + }), + }; + const apis: TechdocsAddonTesterApis = [ [fetchApiRef, fetchApi], + [entityPresentationApiRef, entityPresentationApi], [discoveryApiRef, discoveryApi], [techdocsApiRef, techdocsApi], [techdocsStorageApiRef, techdocsStorageApi], diff --git a/plugins/techdocs-module-addons-contrib/package.json b/plugins/techdocs-module-addons-contrib/package.json index 596eb0e953..5bd72fdfce 100644 --- a/plugins/techdocs-module-addons-contrib/package.json +++ b/plugins/techdocs-module-addons-contrib/package.json @@ -8,9 +8,7 @@ "pluginPackage": "@backstage/plugin-techdocs" }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage", @@ -24,8 +22,23 @@ }, "license": "Apache-2.0", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, "main": "src/index.ts", "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "files": [ "dist" ], @@ -41,6 +54,7 @@ "dependencies": { "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", diff --git a/plugins/techdocs-module-addons-contrib/report-alpha.api.md b/plugins/techdocs-module-addons-contrib/report-alpha.api.md new file mode 100644 index 0000000000..1e203eb306 --- /dev/null +++ b/plugins/techdocs-module-addons-contrib/report-alpha.api.md @@ -0,0 +1,21 @@ +## API Report File for "@backstage/plugin-techdocs-module-addons-contrib" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { FrontendModule } from '@backstage/frontend-plugin-api'; + +// @alpha (undocumented) +export const techDocsExpandableNavigationAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsLightBoxAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsReportIssueAddonModule: FrontendModule; + +// @alpha (undocumented) +export const techDocsTextSizeAddonModule: FrontendModule; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.test.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.test.tsx rename to plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.test.tsx diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx b/plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx rename to plugins/techdocs-module-addons-contrib/src/LightBox/LightBox.tsx diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/index.ts b/plugins/techdocs-module-addons-contrib/src/LightBox/index.ts similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/index.ts rename to plugins/techdocs-module-addons-contrib/src/LightBox/index.ts diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css b/plugins/techdocs-module-addons-contrib/src/LightBox/lightbox.css similarity index 100% rename from plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css rename to plugins/techdocs-module-addons-contrib/src/LightBox/lightbox.css diff --git a/plugins/techdocs-module-addons-contrib/src/alpha.ts b/plugins/techdocs-module-addons-contrib/src/alpha.ts new file mode 100644 index 0000000000..acbb808a55 --- /dev/null +++ b/plugins/techdocs-module-addons-contrib/src/alpha.ts @@ -0,0 +1,86 @@ +/* + * 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 { TechDocsAddonLocations } from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { TextSizeAddon } from './TextSize'; +import { ReportIssueAddon } from './ReportIssue'; +import { ExpandableNavigationAddon } from './ExpandableNavigation'; +import { LightBoxAddon } from './LightBox'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +/** @alpha */ +const techDocsExpandableNavigationAddon = AddonBlueprint.make({ + name: 'expandable-navigation', + params: { + name: 'ExpandableNavigation', + location: TechDocsAddonLocations.PrimarySidebar, + component: ExpandableNavigationAddon, + }, +}); + +/** @alpha */ +export const techDocsExpandableNavigationAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsExpandableNavigationAddon], +}); + +/** @alpha */ +const techDocsReportIssueAddon = AddonBlueprint.make({ + name: 'report-issue', + params: { + name: 'ReportIssue', + location: TechDocsAddonLocations.Content, + component: ReportIssueAddon, + }, +}); + +/** @alpha */ +export const techDocsReportIssueAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsReportIssueAddon], +}); + +/** @alpha */ +const techDocsTextSizeAddon = AddonBlueprint.make({ + name: 'text-size', + params: { + name: 'TextSize', + location: TechDocsAddonLocations.Settings, + component: TextSizeAddon, + }, +}); + +/** @alpha */ +export const techDocsTextSizeAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsTextSizeAddon], +}); + +/** @alpha */ +const techDocsLightBoxAddon = AddonBlueprint.make({ + name: 'light-box', + params: { + name: 'LightBox', + location: TechDocsAddonLocations.Content, + component: LightBoxAddon, + }, +}); + +/** @alpha */ +export const techDocsLightBoxAddonModule = createFrontendModule({ + pluginId: 'techdocs', + extensions: [techDocsLightBoxAddon], +}); diff --git a/plugins/techdocs-module-addons-contrib/src/plugin.ts b/plugins/techdocs-module-addons-contrib/src/plugin.ts index c4023a72af..ac19d682de 100644 --- a/plugins/techdocs-module-addons-contrib/src/plugin.ts +++ b/plugins/techdocs-module-addons-contrib/src/plugin.ts @@ -22,7 +22,7 @@ import { import { ExpandableNavigationAddon } from './ExpandableNavigation'; import { ReportIssueAddon, ReportIssueProps } from './ReportIssue'; import { TextSizeAddon } from './TextSize'; -import { LightBoxAddon } from './LigthBox'; +import { LightBoxAddon } from './LightBox'; /** * The TechDocs addons contrib plugin diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json index d7676ac66a..39b648c64d 100644 --- a/plugins/techdocs-react/package.json +++ b/plugins/techdocs-react/package.json @@ -14,9 +14,7 @@ ] }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage", @@ -30,8 +28,23 @@ }, "license": "Apache-2.0", "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./alpha": "./src/alpha.ts", + "./package.json": "./package.json" + }, "main": "src/index.ts", "types": "src/index.ts", + "typesVersions": { + "*": { + "alpha": [ + "src/alpha.ts" + ], + "package.json": [ + "package.json" + ] + } + }, "files": [ "dist" ], @@ -49,6 +62,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/styles": "^4.11.0", diff --git a/plugins/techdocs-react/report-alpha.api.md b/plugins/techdocs-react/report-alpha.api.md new file mode 100644 index 0000000000..d916d1fcb0 --- /dev/null +++ b/plugins/techdocs-react/report-alpha.api.md @@ -0,0 +1,63 @@ +## API Report File for "@backstage/plugin-techdocs-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ComponentType } from 'react'; +import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; + +// @alpha +export const AddonBlueprint: ExtensionBlueprint<{ + kind: 'addon'; + name: undefined; + params: TechDocsAddonOptions; + output: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + addon: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >; + }; +}>; + +// @alpha (undocumented) +export const attachTechDocsAddonComponentData:

( + techDocsAddon: ComponentType

, + data: TechDocsAddonOptions, +) => void; + +// @alpha (undocumented) +export const techDocsAddonDataRef: ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} +>; + +// @public +export const TechDocsAddonLocations: Readonly<{ + readonly Header: 'Header'; + readonly Subheader: 'Subheader'; + readonly Settings: 'Settings'; + readonly PrimarySidebar: 'PrimarySidebar'; + readonly SecondarySidebar: 'SecondarySidebar'; + readonly Content: 'Content'; +}>; + +// @public +export type TechDocsAddonOptions = { + name: string; + location: keyof typeof TechDocsAddonLocations; + component: ComponentType; +}; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/techdocs-react/src/addons.tsx b/plugins/techdocs-react/src/addons.tsx index 54aea0352d..a012ce9d36 100644 --- a/plugins/techdocs-react/src/addons.tsx +++ b/plugins/techdocs-react/src/addons.tsx @@ -49,7 +49,7 @@ export const TechDocsAddons: React.ComponentType< attachComponentData(TechDocsAddons, TECHDOCS_ADDONS_WRAPPER_KEY, true); -const getDataKeyByName = (name: string) => { +export const getDataKeyByName = (name: string) => { return `${TECHDOCS_ADDONS_KEY}.${name.toLocaleLowerCase('en-US')}`; }; diff --git a/plugins/techdocs-react/src/alpha.ts b/plugins/techdocs-react/src/alpha.ts new file mode 100644 index 0000000000..689ec41977 --- /dev/null +++ b/plugins/techdocs-react/src/alpha.ts @@ -0,0 +1,58 @@ +/* + * 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 { TechDocsAddonOptions } from './types'; +import { attachComponentData } from '@backstage/core-plugin-api'; +import { ComponentType } from 'react'; +import { getDataKeyByName, TECHDOCS_ADDONS_KEY } from './addons'; +import { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; + +/** @alpha */ +export type { TechDocsAddonOptions, TechDocsAddonLocations } from './types'; + +/** @alpha */ +export const techDocsAddonDataRef = + createExtensionDataRef().with({ + id: 'techdocs.addon', + }); + +/** + * Creates an extension to add addons to the TechDocs standalone reader and entity pages. + * @alpha + */ +export const AddonBlueprint = createExtensionBlueprint({ + kind: 'addon', + attachTo: [ + { id: 'page:techdocs/reader', input: 'addons' }, + { id: 'entity-content:techdocs', input: 'addons' }, + ], + output: [techDocsAddonDataRef], + factory: (params: TechDocsAddonOptions) => [techDocsAddonDataRef(params)], + dataRefs: { + addon: techDocsAddonDataRef, + }, +}); + +/** @alpha */ +export const attachTechDocsAddonComponentData =

( + techDocsAddon: ComponentType

, + data: TechDocsAddonOptions, +) => { + attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data); + attachComponentData(techDocsAddon, getDataKeyByName(data.name), true); +}; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 8cc4f07b86..d7a0a3a7d4 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -17,6 +17,7 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; import { SearchResultItemExtensionPredicate } from '@backstage/plugin-search-react/alpha'; import { SearchResultListItemBlueprintParams } from '@backstage/plugin-search-react/alpha'; +import { TechDocsAddonOptions } from '@backstage/plugin-techdocs-react'; // @alpha (undocumented) const _default: FrontendPlugin< @@ -151,8 +152,6 @@ const _default: FrontendPlugin< params: SearchResultListItemBlueprintParams; }>; 'page:techdocs/reader': ExtensionDefinition<{ - kind: 'page'; - name: 'reader'; config: { path: string | undefined; }; @@ -173,7 +172,21 @@ const _default: FrontendPlugin< optional: true; } >; - inputs: {}; + inputs: { + addons: ExtensionInput< + ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'page'; + name: 'reader'; params: { defaultPath: string; loader: () => Promise; @@ -227,13 +240,24 @@ const _default: FrontendPlugin< } > | ConfigurableExtensionDataRef< - string | false, + string, 'catalog.entity-content-group', { optional: true; } >; inputs: { + addons: ExtensionInput< + ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >, + { + singleton: false; + optional: false; + } + >; emptyState: ExtensionInput< ConfigurableExtensionDataRef< React_2.JSX.Element, @@ -255,6 +279,7 @@ const _default: FrontendPlugin< defaultPath: string; defaultTitle: string; defaultGroup?: + | (string & {}) | 'documentation' | 'development' | 'deployment' diff --git a/plugins/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 6a0bc0e08e..635b3886b6 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -56,6 +56,26 @@ export const Router = () => { ); }; +export const TechDocsReaderRouter = (props: PropsWithChildren) => { + const { children } = props; + + // Using objects instead of elements, otherwise "outlet" will be null on sub-pages and add-ons won't render + const element = useRoutes([ + { + path: '*', + element: , + children: [ + { + path: '*', + element: children, + }, + ], + }, + ]); + + return element; +}; + export const EmbeddedDocsRouter = ( props: PropsWithChildren<{ emptyState?: React.ReactElement; @@ -90,7 +110,6 @@ export const EmbeddedDocsRouter = ( ) ); } - return element; }; diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index a9ac353753..9ec6b733c5 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -38,16 +38,20 @@ import { } from '@backstage/core-compat-api'; import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; -import { - techdocsApiRef, - techdocsStorageApiRef, -} from '@backstage/plugin-techdocs-react'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; import { TechDocsClient, TechDocsStorageClient } from './client'; import { rootCatalogDocsRouteRef, rootDocsRouteRef, rootRouteRef, } from './routes'; +import { TechDocsReaderLayout } from './reader'; +import { attachTechDocsAddonComponentData } from '@backstage/plugin-techdocs-react/alpha'; +import { + TechDocsAddons, + techdocsApiRef, + techdocsStorageApiRef, +} from '@backstage/plugin-techdocs-react'; /** @alpha */ const techDocsStorageApi = ApiBlueprint.make({ @@ -138,15 +142,32 @@ const techDocsPage = PageBlueprint.make({ * * @alpha */ -const techDocsReaderPage = PageBlueprint.make({ +const techDocsReaderPage = PageBlueprint.makeWithOverrides({ name: 'reader', - params: { - defaultPath: '/docs/:namespace/:kind/:name', - routeRef: convertLegacyRouteRef(rootDocsRouteRef), - loader: () => - import('./reader/components/TechDocsReaderPage').then(m => - compatWrapper(), - ), + inputs: { + addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), + }, + factory(originalFactory, { inputs }) { + const addons = inputs.addons.map(output => { + const options = output.get(AddonBlueprint.dataRefs.addon); + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); + return ; + }); + + return originalFactory({ + defaultPath: '/docs/:namespace/:kind/:name', + routeRef: convertLegacyRouteRef(rootDocsRouteRef), + loader: async () => + await import('./Router').then(({ TechDocsReaderRouter }) => { + return compatWrapper( + + + {addons} + , + ); + }), + }); }, }); @@ -157,6 +178,7 @@ const techDocsReaderPage = PageBlueprint.make({ */ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ inputs: { + addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), emptyState: createExtensionInput( [coreExtensionData.reactElement.optional()], { @@ -172,15 +194,23 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ defaultTitle: 'TechDocs', routeRef: convertLegacyRouteRef(rootCatalogDocsRouteRef), loader: () => - import('./Router').then(({ EmbeddedDocsRouter }) => - compatWrapper( + import('./Router').then(({ EmbeddedDocsRouter }) => { + const addons = context.inputs.addons.map(output => { + const options = output.get(AddonBlueprint.dataRefs.addon); + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); + return ; + }); + return compatWrapper( , - ), - ), + > + {addons} + , + ); + }), }, context, ); diff --git a/yarn.lock b/yarn.lock index ce76862715..df5408ce22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8113,6 +8113,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" @@ -8196,6 +8197,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^"