Merge branch 'master' into chore/only-upgrade-yarn-to-v4

This commit is contained in:
secustor
2025-02-24 16:47:25 +01:00
59 changed files with 1631 additions and 391 deletions
+5
View File
@@ -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`.
+6
View File
@@ -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`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-common': patch
---
Export `backstage.io/kubernetes-id` and `backstage.io/kubernetes-label-selector` annotations as constants
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Explicitly stringify extra logger fields with `JSON.stringify` to prevent `[object Object]` errors.
+11 -11
View File
@@ -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) {
>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'info')
.filter(card => card.type === 'info')
.map((card, index) => (
<Grid key={index} xs={12} item>
{card.element}
@@ -44,14 +44,14 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) {
<Grid xs={12} md={8} item>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'peek')
.filter(card => card.type === 'peek')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{card.element}
</Grid>
))}
{cards
.filter(card => !card.area || card.area === 'full')
.filter(card => !card.type || card.type === 'full')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{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'
+9 -9
View File
@@ -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
```
+7
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new `ExtensionBoundary.lazyComponent` helper in addition to the existing `ExtensionBoundary.lazy` helper.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend': patch
---
Improved notifications router tests
+204
View File
@@ -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.
<!-- todo: Needs zoomable plugin -->
![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<HTMLImageElement>(['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([
<MakeAllImagesCatGifs />,
])
.withDom(<img data-testid="fixture" src="http://example.com/dog.jpg" />)
.renderWithEffects();
expect(getByTestId('fixture')).toHaveAttribute(
'src',
'https://example.com/cat.gif',
);
});
});
```
+4
View File
@@ -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
+3 -3
View File
@@ -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'
+7 -7
View File
@@ -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) {
>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'info')
.filter(card => card.type === 'info')
.map((card, index) => (
<Grid key={index} xs={12} item>
{card.element}
@@ -67,14 +67,14 @@ function StickyEntityContentOverviewLayout(props: EntityCardLayoutProps) {
<Grid xs={12} md={8} item>
<Grid container spacing={3}>
{cards
.filter(card => card.area === 'peek')
.filter(card => card.type === 'peek')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{card.element}
</Grid>
))}
{cards
.filter(card => !card.area || card.area === 'full')
.filter(card => !card.type || card.type === 'full')
.map((card, index) => (
<Grid key={index} className={classes.card} xs={12} md={6} item>
{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,
@@ -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',
});
});
});
@@ -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 },
);
},
@@ -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"}}}`,
);
});
});
@@ -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]';
}
+6 -1
View File
@@ -964,8 +964,13 @@ export namespace ExtensionBoundary {
// (undocumented)
export function lazy(
appNode: AppNode,
lazyElement: () => Promise<JSX.Element>,
loader: () => Promise<JSX.Element>,
): JSX.Element;
// (undocumented)
export function lazyComponent<TProps extends {}>(
appNode: AppNode,
loader: () => Promise<(props: TProps) => JSX.Element>,
): (props: TProps) => JSX.Element;
}
// @public (undocumented)
@@ -96,10 +96,10 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
export namespace ExtensionBoundary {
export function lazy(
appNode: AppNode,
lazyElement: () => Promise<JSX.Element>,
loader: () => Promise<JSX.Element>,
): JSX.Element {
const ExtensionComponent = reactLazy(() =>
lazyElement().then(element => ({ default: () => element })),
loader().then(element => ({ default: () => element })),
);
return (
<ExtensionBoundary node={appNode}>
@@ -107,4 +107,19 @@ export namespace ExtensionBoundary {
</ExtensionBoundary>
);
}
export function lazyComponent<TProps extends {}>(
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<TProps>;
return (props: TProps) => (
<ExtensionBoundary node={appNode}>
<ExtensionComponent {...props} />
</ExtensionBoundary>
);
}
}
+35 -32
View File
@@ -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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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'
@@ -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<DbRefreshStateRow>('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<DbFinalEntitiesRow>('final_entities').insert([
{
entity_id: 'my-id',
entity_ref: 'k:ns/n',
hash: '',
stitch_ticket: 'old-ticket',
final_entity: JSON.stringify({}),
},
]);
await expect(
knex<DbFinalEntitiesRow>('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<DbFinalEntitiesRow>('final_entities').select([
'entity_id',
'stitch_ticket',
]),
).resolves.toEqual([
{
entity_id: 'my-id',
stitch_ticket: expect.not.stringContaining('old-ticket'),
},
]);
},
);
});
+6 -5
View File
@@ -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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
type?: EntityCardType | undefined;
};
}>;
'page:catalog-graph': ExtensionDefinition<{
+79 -78
View File
@@ -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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
type?: EntityCardType | undefined;
};
output:
| ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
@@ -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;
@@ -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],
},
@@ -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<JSX.Element>;
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}'`,
);
}
},
@@ -70,7 +70,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({
loader: () => Promise<JSX.Element>;
defaultPath: string;
defaultTitle: string;
defaultGroup?: keyof typeof defaultEntityContentGroups;
defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {});
routeRef?: RouteRef;
filter?:
| typeof entityFilterFunctionDataRef.T
@@ -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<typeof ExtensionComponent>) => {
return (
<ExtensionBoundary node={node}>
<ExtensionComponent {...props} />
</ExtensionBoundary>
);
},
ExtensionBoundary.lazyComponent(node, loader),
);
},
});
@@ -45,21 +45,25 @@ export const defaultEntityContentGroups = {
};
/** @internal */
export const entityContentGroupDataRef = createExtensionDataRef<
false | string
>().with({
export const entityContentGroupDataRef = createExtensionDataRef<string>().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<EntityCardType>().with({
id: 'catalog.entity-card-type',
});
@@ -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';
+59 -57
View File
@@ -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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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<JSX_2.Element, 'core.reactElement', {}>
@@ -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<JSX.Element>;
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;
@@ -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 () => <div>Info card</div>,
},
});
@@ -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', () => {
<h3>Custom layout</h3>
<div id="info">
{cards
.filter(card => card.area === 'info')
.filter(card => card.type === 'info')
.map((card, index) => (
<Fragment key={index}>{card.element}</Fragment>
))}
</div>
<div id="other">
{cards
.filter(card => card.area !== 'info')
.filter(card => card.type !== 'info')
.map((card, index) => (
<Fragment key={index}>{card.element}</Fragment>
))}
+15 -11
View File
@@ -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 (
<ExtensionBoundary node={node}>
<LazyDefaultLayoutComponent {...props} />
@@ -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),
+1 -1
View File
@@ -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 {
@@ -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'];
+7
View File
@@ -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;
@@ -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
*
+2 -1
View File
@@ -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'
+4 -4
View File
@@ -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]) ||
@@ -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<DatabaseService> {
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<SignalsService> = {
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);
});
});
});
@@ -180,7 +180,7 @@ export async function createRouter(
const processOptions = async (
opts: NotificationSendOptions,
origin: string,
) => {
): Promise<NotificationSendOptions> => {
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);
+21 -20
View File
@@ -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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
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<JSX.Element>;
filter?: string | ((entity: Entity) => boolean) | undefined;
defaultArea?: 'full' | 'info' | 'peek' | undefined;
type?: EntityCardType | undefined;
};
}>;
}
@@ -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 && \
@@ -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<any[]> = [
[fetchApiRef, fetchApi],
[entityPresentationApiRef, entityPresentationApi],
[discoveryApiRef, discoveryApi],
[techdocsApiRef, techdocsApi],
[techdocsStorageApiRef, techdocsStorageApi],
@@ -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:^",
@@ -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)
```
@@ -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],
});
@@ -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
+17 -3
View File
@@ -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",
@@ -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: <P>(
techDocsAddon: ComponentType<P>,
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<TAddonProps = {}> = {
name: string;
location: keyof typeof TechDocsAddonLocations;
component: ComponentType<TAddonProps>;
};
// (No @packageDocumentation comment for this package)
```
+1 -1
View File
@@ -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')}`;
};
+58
View File
@@ -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<TechDocsAddonOptions>().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 = <P>(
techDocsAddon: ComponentType<P>,
data: TechDocsAddonOptions,
) => {
attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data);
attachComponentData(techDocsAddon, getDataKeyByName(data.name), true);
};
+29 -4
View File
@@ -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<JSX.Element>;
@@ -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'
+20 -1
View File
@@ -56,6 +56,26 @@ export const Router = () => {
);
};
export const TechDocsReaderRouter = (props: PropsWithChildren) => {
const { children } = props;
// Using objects instead of <Route> elements, otherwise "outlet" will be null on sub-pages and add-ons won't render
const element = useRoutes([
{
path: '*',
element: <TechDocsReaderPage />,
children: [
{
path: '*',
element: children,
},
],
},
]);
return element;
};
export const EmbeddedDocsRouter = (
props: PropsWithChildren<{
emptyState?: React.ReactElement;
@@ -90,7 +110,6 @@ export const EmbeddedDocsRouter = (
)
);
}
return element;
};
+47 -17
View File
@@ -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(<m.TechDocsReaderPage />),
),
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 <Addon key={options.name} />;
});
return originalFactory({
defaultPath: '/docs/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(rootDocsRouteRef),
loader: async () =>
await import('./Router').then(({ TechDocsReaderRouter }) => {
return compatWrapper(
<TechDocsReaderRouter>
<TechDocsReaderLayout />
<TechDocsAddons>{addons}</TechDocsAddons>
</TechDocsReaderRouter>,
);
}),
});
},
});
@@ -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 <Addon key={options.name} />;
});
return compatWrapper(
<EmbeddedDocsRouter
emptyState={context.inputs.emptyState?.get(
coreExtensionData.reactElement,
)}
/>,
),
),
>
<TechDocsAddons>{addons}</TechDocsAddons>
</EmbeddedDocsRouter>,
);
}),
},
context,
);
+2
View File
@@ -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:^"