Merge pull request #21481 from backstage/mob/naming

frontend-*-api: change how extension IDs are defined
This commit is contained in:
Patrik Oldsberg
2023-11-28 18:45:10 +01:00
committed by GitHub
88 changed files with 1192 additions and 763 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Updates for compatibility with the new extension IDs.
+17
View File
@@ -0,0 +1,17 @@
---
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-stack-overflow': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-user-settings': patch
'@backstage/plugin-search-react': patch
'@backstage/plugin-tech-radar': patch
'@backstage/plugin-graphiql': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-explore': patch
'@backstage/plugin-search': patch
'@backstage/plugin-home': patch
'@backstage/plugin-adr': patch
---
Refactor of the alpha exports due to API change in how extension IDs are constructed.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Updates to match the introduction of `ExtensionDefinition` and new extension ID naming patterns.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Updates for compatibility with the new extension IDs.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/frontend-plugin-api': minor
---
**BREAKING**: This version changes how extensions are created and how their IDs are determined. The `createExtension` function now accepts `kind`, `namespace` and `name` instead of `id`. All of the new options are optional, and are used to construct the final extension ID. By convention extension creators should set the `kind` to match their own name, for example `createNavItemExtension` sets the kind `nav-item`.
The `createExtension` function as well as all extension creators now also return an `ExtensionDefinition` rather than an `Extension`, which in turn needs to be passed to `createPlugin` or `createExtensionOverrides` to be used.
@@ -21,7 +21,6 @@ import {
} from '@backstage/frontend-plugin-api';
export const ExamplePage = createPageExtension({
id: 'example.page',
defaultPath: '/example',
loader: () => import('./Component').then(m => <m.Component />),
});
+6 -6
View File
@@ -7,17 +7,17 @@ app:
plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
extensions:
- apis.plugin.graphiql.browse.gitlab: true
# - apis.plugin.graphiql.browse.gitlab: true
- graphiql-endpoint:graphiql/gitlab: true
# Entity page cards
- entity.cards.about
- entity.cards.labels
- entity.cards.links:
- entity-card:catalog/about
- entity-card:catalog/labels
- entity-card:catalog/links:
config:
filter: kind:component has:links
# Entity page content
- entity.content.techdocs
- entity-content:techdocs
# scmAuthExtension: >-
# createScmAuthExtension({
+2 -2
View File
@@ -83,7 +83,7 @@ TODO:
/* app.tsx */
const homePageExtension = createExtension({
id: 'myhomepage',
name: 'myhomepage',
attachTo: { id: 'home', input: 'props' },
output: {
children: coreExtensionData.reactElement,
@@ -95,7 +95,7 @@ const homePageExtension = createExtension({
});
const signInPage = createSignInPageExtension({
id: 'signInPage',
name: 'guest',
loader: async () => (props: SignInPageProps) =>
<SignInPage {...props} providers={['guest']} />,
});
@@ -36,7 +36,7 @@ export const pageXRouteRef = createRouteRef();
// });
const IndexPage = createPageExtension({
id: 'index',
name: 'index',
defaultPath: '/',
routeRef: indexRouteRef,
loader: async () => {
@@ -68,7 +68,7 @@ const IndexPage = createPageExtension({
});
const Page1 = createPageExtension({
id: 'page1',
name: 'page1',
defaultPath: '/page1',
routeRef: page1RouteRef,
loader: async () => {
@@ -102,7 +102,7 @@ const Page1 = createPageExtension({
});
const ExternalPage = createPageExtension({
id: 'pageX',
name: 'pageX',
defaultPath: '/pageX',
routeRef: pageXRouteRef,
loader: async () => {
+2 -2
View File
@@ -8,7 +8,7 @@
import { AnyRouteRefParams } from '@backstage/core-plugin-api';
import { AppComponents } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api';
@@ -21,7 +21,7 @@ import { SubRouteRef as SubRouteRef_2 } from '@backstage/frontend-plugin-api';
// @public (undocumented)
export function collectLegacyComponents(
components: Partial<AppComponents>,
): Extension<unknown>[];
): ExtensionDefinition<unknown>[];
// @public (undocumented)
export function collectLegacyRoutes(
@@ -31,28 +31,33 @@ describe('collectLegacyComponents', () => {
expect(
collected.map(p => ({
id: p.id,
kind: p.kind,
namespace: p.namespace,
attachTo: p.attachTo,
disabled: p.disabled,
})),
).toEqual([
{
id: 'core.components.progress',
kind: 'component',
namespace: 'core.components.progress',
attachTo: { id: 'core', input: 'components' },
disabled: false,
},
{
id: 'core.components.bootErrorPage',
kind: 'component',
namespace: 'core.components.bootErrorPage',
attachTo: { id: 'core', input: 'components' },
disabled: false,
},
{
id: 'core.components.notFoundErrorPage',
kind: 'component',
namespace: 'core.components.notFoundErrorPage',
attachTo: { id: 'core', input: 'components' },
disabled: false,
},
{
id: 'core.components.errorBoundaryFallback',
kind: 'component',
namespace: 'core.components.errorBoundaryFallback',
attachTo: { id: 'core', input: 'components' },
disabled: false,
},
@@ -15,10 +15,10 @@
*/
import {
Extension,
ComponentRef,
createComponentExtension,
coreComponentsRefs,
ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { AppComponents } from '@backstage/core-plugin-api';
@@ -33,7 +33,7 @@ const refs: Record<string, ComponentRef<ComponentTypes>> = {
/** @public */
export function collectLegacyComponents(components: Partial<AppComponents>) {
return Object.entries(components).reduce<Extension<unknown>[]>(
return Object.entries(components).reduce<ExtensionDefinition<unknown>[]>(
(extensions, [name, component]) => {
const ref = refs[name];
return ref
@@ -51,13 +51,13 @@ describe('collectLegacyRoutes', () => {
id: 'score-card',
extensions: [
{
id: 'plugin.score-card.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:score-card',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'score-board' },
},
{
id: 'apis.plugin.scoringdata.service',
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -67,13 +67,13 @@ describe('collectLegacyRoutes', () => {
id: 'stackstorm',
extensions: [
{
id: 'plugin.stackstorm.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:stackstorm',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'stackstorm' },
},
{
id: 'apis.plugin.stackstorm.service',
id: 'api:plugin.stackstorm.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -83,19 +83,19 @@ describe('collectLegacyRoutes', () => {
id: 'puppetDb',
extensions: [
{
id: 'plugin.puppetDb.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:puppetDb',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:puppetDb/2',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
id: 'api:plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -16,11 +16,11 @@
import React, { ReactNode } from 'react';
import {
Extension,
createApiExtension,
createPageExtension,
createPlugin,
BackstagePlugin,
ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { Route, Routes } from 'react-router-dom';
import {
@@ -64,7 +64,7 @@ export function collectLegacyRoutes(
): BackstagePlugin[] {
const createdPluginIds = new Map<
LegacyBackstagePlugin,
Extension<unknown>[]
ExtensionDefinition<unknown>[]
>();
React.Children.forEach(
@@ -95,19 +95,18 @@ export function collectLegacyRoutes(
'core.mountPoint',
);
const pluginId = plugin.getId();
const detectedExtensions =
createdPluginIds.get(plugin) ?? new Array<Extension<unknown>>();
createdPluginIds.get(plugin) ??
new Array<ExtensionDefinition<unknown>>();
createdPluginIds.set(plugin, detectedExtensions);
const path: string = route.props.path;
detectedExtensions.push(
createPageExtension({
id: `plugin.${pluginId}.page${
detectedExtensions.length ? detectedExtensions.length + 1 : ''
}`,
name: detectedExtensions.length
? String(detectedExtensions.length + 1)
: undefined,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
@@ -132,9 +131,7 @@ export function collectLegacyRoutes(
extensions: [
...extensions,
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
createApiExtension({ factory }),
),
],
}),
@@ -59,13 +59,13 @@ describe('convertLegacyApp', () => {
id: 'score-card',
extensions: [
{
id: 'plugin.score-card.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:score-card',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'score-board' },
},
{
id: 'apis.plugin.scoringdata.service',
id: 'api:plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -75,13 +75,13 @@ describe('convertLegacyApp', () => {
id: 'stackstorm',
extensions: [
{
id: 'plugin.stackstorm.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:stackstorm',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'stackstorm' },
},
{
id: 'apis.plugin.stackstorm.service',
id: 'api:plugin.stackstorm.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -91,19 +91,19 @@ describe('convertLegacyApp', () => {
id: 'puppetDb',
extensions: [
{
id: 'plugin.puppetDb.page',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:puppetDb',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
id: 'page:puppetDb/2',
attachTo: { id: 'core/routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
id: 'api:plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
@@ -113,13 +113,13 @@ describe('convertLegacyApp', () => {
id: undefined,
extensions: [
{
id: 'core.layout',
id: 'core/layout',
attachTo: { id: 'core', input: 'root' },
disabled: false,
},
{
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
id: 'core/nav',
attachTo: { id: 'core/layout', input: 'nav' },
disabled: true,
},
],
@@ -100,7 +100,8 @@ export function convertLegacyApp(
const [routesEl] = routesEls;
const CoreLayoutOverride = createExtension({
id: 'core.layout',
namespace: 'core',
name: 'layout',
attachTo: { id: 'core', input: 'root' },
inputs: {
content: createExtensionInput(
@@ -121,8 +122,9 @@ export function convertLegacyApp(
},
});
const CoreNavOverride = createExtension({
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
namespace: 'core',
name: 'nav',
attachTo: { id: 'core/layout', input: 'nav' },
output: {},
factory: () => ({}),
disabled: true,
@@ -21,7 +21,7 @@ import {
} from '@backstage/frontend-plugin-api';
export const Core = createExtension({
id: 'core',
namespace: 'core',
attachTo: { id: 'root', input: 'default' }, // ignored
inputs: {
apis: createExtensionInput({
@@ -23,8 +23,9 @@ import {
import { SidebarPage } from '@backstage/core-components';
export const CoreLayout = createExtension({
id: 'core.layout',
attachTo: { id: 'core.router', input: 'children' },
namespace: 'core',
name: 'layout',
attachTo: { id: 'core/router', input: 'children' },
inputs: {
nav: createExtensionInput(
{
@@ -75,8 +75,9 @@ const SidebarNavItem = (props: NavTarget) => {
};
export const CoreNav = createExtension({
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
namespace: 'core',
name: 'nav',
attachTo: { id: 'core/layout', input: 'nav' },
inputs: {
items: createExtensionInput({
target: coreExtensionData.navTarget,
@@ -36,7 +36,8 @@ import { signInPageComponentDataRef } from '../../../frontend-plugin-api/src/ext
import { RouteTracker } from '../routing/RouteTracker';
export const CoreRouter = createExtension({
id: 'core.router',
namespace: 'core',
name: 'router',
attachTo: { id: 'core', input: 'root' },
inputs: {
signInPage: createExtensionInput(
@@ -26,8 +26,9 @@ import {
import { useRoutes } from 'react-router-dom';
export const CoreRoutes = createExtension({
id: 'core.routes',
attachTo: { id: 'core.layout', input: 'content' },
namespace: 'core',
name: 'routes',
attachTo: { id: 'core/layout', input: 'content' },
inputs: {
routes: createExtensionInput({
path: coreExtensionData.routePath,
@@ -20,7 +20,7 @@ import { extractRouteInfoFromAppNode } from './extractRouteInfoFromAppNode';
import {
AnyRouteRefParams,
AppNode,
Extension,
ExtensionDefinition,
RouteRef,
coreExtensionData,
createExtension,
@@ -40,16 +40,16 @@ const ref5 = createRouteRef();
const refOrder: RouteRef<AnyRouteRefParams>[] = [ref1, ref2, ref3, ref4, ref5];
function createTestExtension(options: {
id: string;
name: string;
parent?: string;
path?: string;
routeRef?: RouteRef;
}) {
return createExtension({
id: options.id,
name: options.name,
attachTo: options.parent
? { id: options.parent, input: 'children' }
: { id: 'core.routes', input: 'routes' },
? { id: `test/${options.parent}`, input: 'children' }
: { id: 'core/routes', input: 'routes' },
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath.optional(),
@@ -70,14 +70,14 @@ function createTestExtension(options: {
});
}
function routeInfoFromExtensions(extensions: Extension<unknown>[]) {
function routeInfoFromExtensions(extensions: ExtensionDefinition<unknown>[]) {
const plugin = createPlugin({
id: 'test',
extensions,
});
const tree = createAppTree({
builtinExtensions,
config: new MockConfigApi({}),
builtinExtensions,
features: [plugin],
});
@@ -122,33 +122,33 @@ describe('discovery', () => {
it('should collect routes', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'nothing',
name: 'nothing',
path: 'nothing',
}),
createTestExtension({
id: 'page1',
name: 'page1',
path: 'foo',
routeRef: ref1,
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'page1',
path: 'bar/:id',
routeRef: ref2,
}),
createTestExtension({
id: 'page3',
name: 'page3',
parent: 'page2',
path: 'baz',
routeRef: ref3,
}),
createTestExtension({
id: 'page4',
name: 'page4',
path: 'divsoup',
routeRef: ref4,
}),
createTestExtension({
id: 'page5',
name: 'page5',
parent: 'page1',
path: 'blop',
routeRef: ref5,
@@ -226,29 +226,29 @@ describe('discovery', () => {
it('should handle all react router Route patterns', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'page1',
name: 'page1',
path: 'foo',
routeRef: ref1,
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'page1',
path: 'bar/:id',
routeRef: ref2,
}),
createTestExtension({
id: 'page3',
name: 'page3',
path: 'baz',
routeRef: ref3,
}),
createTestExtension({
id: 'page4',
name: 'page4',
parent: 'page3',
path: 'divsoup',
routeRef: ref4,
}),
createTestExtension({
id: 'page5',
name: 'page5',
parent: 'page3',
path: 'blop',
routeRef: ref5,
@@ -274,29 +274,29 @@ describe('discovery', () => {
it('should strip leading slashes in route paths', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'page1',
name: 'page1',
path: '/foo',
routeRef: ref1,
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'page1',
path: '/bar/:id',
routeRef: ref2,
}),
createTestExtension({
id: 'page3',
name: 'page3',
path: '/baz',
routeRef: ref3,
}),
createTestExtension({
id: 'page4',
name: 'page4',
parent: 'page3',
path: '/divsoup',
routeRef: ref4,
}),
createTestExtension({
id: 'page5',
name: 'page5',
parent: 'page3',
path: '/blop',
routeRef: ref5,
@@ -322,44 +322,44 @@ describe('discovery', () => {
it('should use the route aggregator key to bind child routes to the same path', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'foo',
name: 'foo',
path: 'foo',
}),
createTestExtension({
id: 'page1',
name: 'page1',
parent: 'foo',
routeRef: ref1,
}),
createTestExtension({
id: 'fooChild',
name: 'fooChild',
parent: 'foo',
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'fooChild',
routeRef: ref2,
}),
createTestExtension({
id: 'fooEmpty',
name: 'fooEmpty',
parent: 'foo',
}),
createTestExtension({
id: 'page3',
name: 'page3',
path: 'bar',
routeRef: ref3,
}),
createTestExtension({
id: 'page3Child',
name: 'page3Child',
parent: 'page3',
path: '',
}),
createTestExtension({
id: 'page4',
name: 'page4',
parent: 'page3Child',
routeRef: ref4,
}),
createTestExtension({
id: 'page5',
name: 'page5',
parent: 'page4',
routeRef: ref5,
}),
@@ -411,34 +411,34 @@ describe('discovery', () => {
it('should use the route aggregator but stop when encountering explicit path', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'page1',
name: 'page1',
path: 'foo',
routeRef: ref1,
}),
createTestExtension({
id: 'page1Child',
name: 'page1Child',
parent: 'page1',
path: 'bar',
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'page1Child',
routeRef: ref2,
}),
createTestExtension({
id: 'page3',
name: 'page3',
parent: 'page2',
path: 'baz',
routeRef: ref3,
}),
createTestExtension({
id: 'page4',
name: 'page4',
parent: 'page3',
path: '/blop',
routeRef: ref4,
}),
createTestExtension({
id: 'page5',
name: 'page5',
parent: 'page2',
routeRef: ref5,
}),
@@ -500,34 +500,34 @@ describe('discovery', () => {
it('should account for loose route paths', () => {
const info = routeInfoFromExtensions([
createTestExtension({
id: 'r',
name: 'r',
path: 'r',
}),
createTestExtension({
id: 'page1',
name: 'page1',
parent: 'r',
path: 'x',
routeRef: ref1,
}),
createTestExtension({
id: 'y',
name: 'y',
path: 'y',
parent: 'r',
}),
createTestExtension({
id: 'page2',
name: 'page2',
parent: 'y',
path: '1',
routeRef: ref2,
}),
createTestExtension({
id: 'page3',
name: 'page3',
parent: 'page2',
path: 'a',
routeRef: ref3,
}),
createTestExtension({
id: 'page4',
name: 'page4',
parent: 'page2',
path: 'b',
routeRef: ref4,
@@ -54,12 +54,11 @@ describe('createAppTree', () => {
it('throws an error when a core extension is overridden', () => {
const config = new MockConfigApi({});
const features = [
createPlugin({
id: 'plugin',
createExtensionOverrides({
extensions: [
createExtension({
id: 'core',
attachTo: { id: 'core.routes', input: 'route' },
name: 'core',
attachTo: { id: 'core/routes', input: 'route' },
inputs: {},
output: {},
factory: () => ({}),
@@ -70,33 +69,7 @@ describe('createAppTree', () => {
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow(
"It is forbidden to override the following extension(s): 'core', which is done by the following plugin(s): 'plugin'",
);
});
it('throws an error when duplicated extensions are detected', () => {
const config = new MockConfigApi({});
const ExtensionA = createExtension({ ...extBase, id: 'A' });
const ExtensionB = createExtension({ ...extBase, id: 'B' });
const PluginA = createPlugin({
id: 'A',
extensions: [ExtensionA, ExtensionA],
});
const PluginB = createPlugin({
id: 'B',
extensions: [ExtensionA, ExtensionB, ExtensionB],
});
const features = [PluginA, PluginB];
expect(() =>
createAppTree({ features, config, builtinExtensions: [] }),
).toThrow(
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
"It is forbidden to override the following extension(s): 'core', which is done by one or more extension overrides",
);
});
@@ -106,13 +79,13 @@ describe('createAppTree', () => {
features: [
createExtensionOverrides({
extensions: [
createExtension({ ...extBase, id: 'a' }),
createExtension({ ...extBase, id: 'a' }),
createExtension({ ...extBase, id: 'b' }),
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'a' }),
createExtension({ ...extBase, name: 'b' }),
],
}),
createExtensionOverrides({
extensions: [createExtension({ ...extBase, id: 'b' })],
extensions: [createExtension({ ...extBase, name: 'b' })],
}),
],
config: new MockConfigApi({}),
@@ -28,28 +28,33 @@ import {
} from './instantiateAppNodeTree';
import { AppNodeInstance, AppNodeSpec } from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
const testDataRef = createExtensionDataRef<string>('test');
const otherDataRef = createExtensionDataRef<number>('other');
const inputMirrorDataRef = createExtensionDataRef<unknown>('mirror');
const simpleExtension = createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ config }) {
return { test: config.output, other: config.other };
},
});
const simpleExtension = resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ config }) {
return { test: config.output, other: config.other };
},
}),
);
function makeSpec<TConfig>(
extension: Extension<TConfig>,
@@ -117,19 +122,21 @@ describe('instantiateAppNodeTree', () => {
it('should instantiate a node with attachments', () => {
const tree = resolveAppTree('root-node', [
makeSpec(
createExtension({
id: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
resolveExtensionDefinition(
createExtension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
),
makeSpec(simpleExtension, {
id: 'child-node',
@@ -159,19 +166,21 @@ describe('instantiateAppNodeTree', () => {
const tree = resolveAppTree('root-node', [
{
...makeSpec(
createExtension({
id: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
resolveExtensionDefinition(
createExtension({
namespace: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
),
},
{
@@ -242,43 +251,46 @@ describe('createAppNodeInstance', () => {
const instance = createAppNodeInstance({
attachments,
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
optionalSingletonPresent: createExtensionInput(
{
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
optionalSingletonPresent: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createExtensionInput({
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createExtensionInput({
test: testDataRef,
other: otherDataRef.optional(),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
),
});
@@ -297,7 +309,7 @@ describe('createAppNodeInstance', () => {
attachments: new Map(),
}),
).toThrow(
"Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
"Invalid configuration for extension 'core/test'; caused by Error: Expected number, received string at 'other'",
);
});
@@ -305,21 +317,24 @@ describe('createAppNodeInstance', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
"Failed to instantiate extension 'core/test'; caused by NopeError: NOPE",
);
});
@@ -327,22 +342,25 @@ describe('createAppNodeInstance', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({}) {
return { test1: 'test', test2: 'test2' };
},
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({}) {
return { test1: 'test', test2: 'test2' };
},
}),
),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
"Failed to instantiate extension 'core/test', duplicate extension data 'test' received via output 'test2'",
);
});
@@ -350,21 +368,24 @@ describe('createAppNodeInstance', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
},
factory({}) {
return { nonexistent: 'test' } as any;
},
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
},
factory({}) {
return { nonexistent: 'test' } as any;
},
}),
),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
"Failed to instantiate extension 'core/test', unknown output provided via 'nonexistent'",
);
});
@@ -372,25 +393,28 @@ describe('createAppNodeInstance', () => {
expect(() =>
createAppNodeInstance({
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
"Failed to instantiate extension 'core/test', input 'singleton' is required but was not received",
);
});
@@ -416,21 +440,24 @@ describe('createAppNodeInstance', () => {
],
]),
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createExtensionInput({
test: testDataRef,
}),
},
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createExtensionInput({
test: testDataRef,
}),
},
output: {},
factory: () => ({}),
}),
),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'",
"Failed to instantiate extension 'core/test', received undeclared input 'undeclared' from extension 'core/test'",
);
});
@@ -451,16 +478,19 @@ describe('createAppNodeInstance', () => {
],
]),
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory: () => ({}),
}),
),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'",
"Failed to instantiate extension 'core/test', received undeclared inputs 'undeclared1' from extension 'core/test' and 'undeclared2' from extensions 'core/test', 'core/test'",
);
});
@@ -477,24 +507,27 @@ describe('createAppNodeInstance', () => {
],
]),
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
"Failed to instantiate extension 'core/test', expected exactly one 'singleton' input but received multiple: 'core/test', 'core/test'",
);
});
@@ -511,24 +544,27 @@ describe('createAppNodeInstance', () => {
],
]),
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory: () => ({}),
}),
),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
"Failed to instantiate extension 'core/test', expected at most one 'singleton' input but received multiple: 'core/test', 'core/test'",
);
});
@@ -539,24 +575,27 @@ describe('createAppNodeInstance', () => {
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
]),
node: makeNode(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
resolveExtensionDefinition(
createExtension({
namespace: 'core',
name: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory: () => ({}),
}),
),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
"Failed to instantiate extension 'core/test', input 'singleton' did not receive required extension data 'other' from extension 'core/test'",
);
});
});
@@ -25,18 +25,18 @@ describe('readAppExtensionsConfig', () => {
it('should disable extension with shorthand notation', () => {
expect(
readAppExtensionsConfig(
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
new ConfigReader({ app: { extensions: [{ 'core/router': false }] } }),
),
).toEqual([
{
id: 'core.router',
id: 'core/router',
disabled: true,
},
]);
expect(
readAppExtensionsConfig(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: true } }] },
app: { extensions: [{ 'core/router': { disabled: true } }] },
}),
),
).toEqual([
@@ -44,7 +44,7 @@ describe('readAppExtensionsConfig', () => {
at: undefined,
config: undefined,
disabled: true,
id: 'core.router',
id: 'core/router',
},
]);
});
@@ -52,33 +52,33 @@ describe('readAppExtensionsConfig', () => {
it('should enable extension with shorthand notation', () => {
expect(
readAppExtensionsConfig(
new ConfigReader({ app: { extensions: ['core.router'] } }),
new ConfigReader({ app: { extensions: ['core/router'] } }),
),
).toEqual([
{
id: 'core.router',
id: 'core/router',
disabled: false,
},
]);
expect(
readAppExtensionsConfig(
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
new ConfigReader({ app: { extensions: [{ 'core/router': true }] } }),
),
).toEqual([
{
id: 'core.router',
id: 'core/router',
disabled: false,
},
]);
expect(
readAppExtensionsConfig(
new ConfigReader({
app: { extensions: [{ 'core.router': { disabled: false } }] },
app: { extensions: [{ 'core/router': { disabled: false } }] },
}),
),
).toEqual([
{
id: 'core.router',
id: 'core/router',
disabled: false,
},
]);
@@ -89,12 +89,12 @@ describe('readAppExtensionsConfig', () => {
readAppExtensionsConfig(
new ConfigReader({
app: {
extensions: [{ 'core.router': 'some-string' }],
extensions: [{ 'core/router': 'some-string' }],
},
}),
),
).toThrow(
'Invalid extension configuration at app.extensions[0][core.router], value must be a boolean or object',
'Invalid extension configuration at app.extensions[0][core/router], value must be a boolean or object',
);
});
@@ -105,7 +105,7 @@ describe('readAppExtensionsConfig', () => {
app: {
extensions: [
{
'core.router/routes': {
'': {
extension: 'example-package#MyPage',
config: { foo: 'bar' },
},
@@ -115,7 +115,7 @@ describe('readAppExtensionsConfig', () => {
}),
),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[0], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
`"Invalid extension configuration at app.extensions[0], extension ID must not be empty or contain whitespace"`,
);
});
});
@@ -156,8 +156,8 @@ describe('expandShorthandExtensionParameters', () => {
});
it('supports string key', () => {
expect(run('core.router')).toEqual({
id: 'core.router',
expect(run('core/router')).toEqual({
id: 'core/router',
disabled: false,
});
expect(() => run('')).toThrowErrorMatchingInlineSnapshot(
@@ -166,103 +166,100 @@ describe('expandShorthandExtensionParameters', () => {
expect(() => run(' a')).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1], extension ID must not be empty or contain whitespace"`,
);
expect(() => run('core.router/routes')).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
);
});
it('supports null value', () => {
// this is the result of typing:
// - core.router:
// - core/router:
// The missing value is interpreted as null by the yaml parser so we deal with that
expect(run({ 'core.router': null })).toEqual({
id: 'core.router',
expect(run({ 'core/router': null })).toEqual({
id: 'core/router',
disabled: false,
});
});
it('supports boolean value', () => {
expect(run({ 'core.router': true })).toEqual({
id: 'core.router',
expect(run({ 'core/router': true })).toEqual({
id: 'core/router',
disabled: false,
});
expect(run({ 'core.router': false })).toEqual({
id: 'core.router',
expect(run({ 'core/router': false })).toEqual({
id: 'core/router',
disabled: true,
});
});
it('should not support string values', () => {
expect(() =>
run({ 'core.router': 'example-package#MyRouter' }),
run({ 'core/router': 'example-package#MyRouter' }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router], value must be a boolean or object"`,
`"Invalid extension configuration at app.extensions[1][core/router], value must be a boolean or object"`,
);
});
it('supports object id only in the key', () => {
expect(() =>
run({ 'core.router': { id: 'some.id' } }),
run({ 'core/router': { id: 'some.id' } }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
`"Invalid extension configuration at app.extensions[1][core/router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
);
});
it('supports object attachTo', () => {
expect(
run({
'core.router': { attachTo: { id: 'other.root', input: 'inputs' } },
'core/router': { attachTo: { id: 'other.root', input: 'inputs' } },
}),
).toEqual({
id: 'core.router',
id: 'core/router',
attachTo: { id: 'other.root', input: 'inputs' },
});
expect(() =>
run({
'core.router': {
'core/router': {
id: 'other-id',
},
}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
`"Invalid extension configuration at app.extensions[1][core/router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
);
});
it('supports object disabled', () => {
expect(run({ 'core.router': { disabled: true } })).toEqual({
id: 'core.router',
expect(run({ 'core/router': { disabled: true } })).toEqual({
id: 'core/router',
disabled: true,
});
expect(run({ 'core.router': { disabled: false } })).toEqual({
id: 'core.router',
expect(run({ 'core/router': { disabled: false } })).toEqual({
id: 'core/router',
disabled: false,
});
expect(() =>
run({ 'core.router': { disabled: 0 } }),
run({ 'core/router': { disabled: 0 } }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router].disabled, must be a boolean"`,
`"Invalid extension configuration at app.extensions[1][core/router].disabled, must be a boolean"`,
);
});
it('supports object config', () => {
expect(
run({ 'core.router': { config: { disableRedirects: true } } }),
run({ 'core/router': { config: { disableRedirects: true } } }),
).toEqual({
id: 'core.router',
id: 'core/router',
config: { disableRedirects: true },
});
expect(() =>
run({ 'core.router': { config: 0 } }),
run({ 'core/router': { config: 0 } }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router].config, must be an object"`,
`"Invalid extension configuration at app.extensions[1][core/router].config, must be an object"`,
);
});
it('rejects unknown object keys', () => {
expect(() =>
run({ 'core.router': { foo: { settings: true } } }),
run({ 'core/router': { foo: { settings: true } } }),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid extension configuration at app.extensions[1][core.router].foo, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
`"Invalid extension configuration at app.extensions[1][core/router].foo, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
);
});
});
@@ -70,15 +70,6 @@ export function expandShorthandExtensionParameters(
errorMsg('extension ID must not be empty or contain whitespace'),
);
}
if (id.includes('/')) {
let message = `extension ID must not contain slashes; got '${id}'`;
const good = id.split('/')[0];
if (good) {
message += `, did you mean '${good}'?`;
}
throw new Error(errorMsg(message));
}
}
// Example YAML:
@@ -18,6 +18,7 @@ import {
createExtensionOverrides,
createPlugin,
Extension,
ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
@@ -27,12 +28,26 @@ function makeExt(
attachId: string = 'root',
) {
return {
$$type: '@backstage/Extension',
id,
attachTo: { id: attachId, input: 'default' },
disabled: status === 'disabled',
} as Extension<unknown>;
}
function makeExtDef(
name: string,
status: 'disabled' | 'enabled' = 'enabled',
attachId: string = 'root',
) {
return {
$$type: '@backstage/ExtensionDefinition',
name,
attachTo: { id: attachId, input: 'default' },
disabled: status === 'disabled',
} as ExtensionDefinition<unknown>;
}
describe('resolveAppNodeSpecs', () => {
it('should not filter out disabled extension instances', () => {
const a = makeExt('a', 'disabled');
@@ -78,9 +93,8 @@ describe('resolveAppNodeSpecs', () => {
});
it('should override attachment points', () => {
const a = makeExt('a');
const b = makeExt('b');
const pluginA = createPlugin({ id: 'test', extensions: [a] });
const pluginA = createPlugin({ id: 'test', extensions: [makeExtDef('a')] });
expect(
resolveAppNodeSpecs({
features: [pluginA],
@@ -94,8 +108,8 @@ describe('resolveAppNodeSpecs', () => {
}),
).toEqual([
{
id: 'a',
extension: a,
id: 'test/a',
extension: makeExt('test/a'),
attachTo: { id: 'root', input: 'default' },
source: pluginA,
disabled: false,
@@ -110,31 +124,34 @@ describe('resolveAppNodeSpecs', () => {
});
it('should fully override configuration and duplicate', () => {
const a = makeExt('a');
const b = makeExt('b');
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
const a = makeExt('test/a');
const b = makeExt('test/b');
const plugin = createPlugin({
id: 'test',
extensions: [makeExtDef('a'), makeExtDef('b')],
});
expect(
resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [
{
id: 'a',
id: 'test/a',
config: { foo: { bar: 1 } },
},
{
id: 'b',
id: 'test/b',
config: { foo: { bar: 2 } },
},
{
id: 'b',
id: 'test/b',
config: { foo: { qux: 3 } },
},
],
}),
).toEqual([
{
id: 'a',
id: 'test/a',
extension: a,
attachTo: { id: 'root', input: 'default' },
source: plugin,
@@ -142,7 +159,7 @@ describe('resolveAppNodeSpecs', () => {
disabled: false,
},
{
id: 'b',
id: 'test/b',
extension: b,
attachTo: { id: 'root', input: 'default' },
source: plugin,
@@ -187,11 +204,12 @@ describe('resolveAppNodeSpecs', () => {
});
it('should apply extension overrides', () => {
const a = makeExt('a');
const b = makeExt('b');
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
const aOverride = makeExt('a', 'enabled', 'other');
const bOverride = makeExt('b', 'disabled', 'other');
const plugin = createPlugin({
id: 'test',
extensions: [makeExtDef('a'), makeExtDef('b')],
});
const aOverride = makeExt('test/a', 'enabled', 'other');
const bOverride = makeExt('test/b', 'disabled', 'other');
const cOverride = makeExt('c');
expect(
@@ -199,7 +217,11 @@ describe('resolveAppNodeSpecs', () => {
features: [
plugin,
createExtensionOverrides({
extensions: [aOverride, bOverride, cOverride],
extensions: [
makeExtDef('test/a', 'enabled', 'other'),
makeExtDef('test/b', 'disabled', 'other'),
makeExtDef('c'),
],
}),
],
builtinExtensions: [],
@@ -207,14 +229,14 @@ describe('resolveAppNodeSpecs', () => {
}),
).toEqual([
{
id: 'a',
id: 'test/a',
extension: aOverride,
attachTo: { id: 'other', input: 'default' },
source: plugin,
disabled: false,
},
{
id: 'b',
id: 'test/b',
extension: bOverride,
attachTo: { id: 'other', input: 'default' },
source: plugin,
@@ -231,39 +253,50 @@ describe('resolveAppNodeSpecs', () => {
});
it('should use order from configuration when rather than overrides', () => {
const a = makeExt('a', 'disabled');
const b = makeExt('b', 'disabled');
const c = makeExt('c', 'disabled');
const aOverride = makeExt('c', 'disabled');
const bOverride = makeExt('b', 'disabled');
const cOverride = makeExt('a', 'disabled');
const result = resolveAppNodeSpecs({
features: [
createPlugin({ id: 'test', extensions: [a, b, c] }),
createPlugin({
id: 'test',
extensions: [
makeExtDef('a', 'disabled'),
makeExtDef('b', 'disabled'),
makeExtDef('c', 'disabled'),
],
}),
createExtensionOverrides({
extensions: [cOverride, bOverride, aOverride],
extensions: [
makeExtDef('test/c', 'disabled'),
makeExtDef('test/b', 'disabled'),
makeExtDef('test/a', 'disabled'),
],
}),
],
builtinExtensions: [],
parameters: ['b', 'c', 'a'].map(id => ({ id, disabled: false })),
parameters: ['test/b', 'test/c', 'test/a'].map(id => ({
id,
disabled: false,
})),
});
expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
expect(result.map(r => r.extension.id)).toEqual([
'test/b',
'test/c',
'test/a',
]);
});
it('throws an error when a forbidden extension is overridden by a plugin', () => {
expect(() =>
resolveAppNodeSpecs({
features: [
createPlugin({ id: 'test', extensions: [makeExt('forbidden')] }),
createPlugin({ id: 'test', extensions: [makeExtDef('forbidden')] }),
],
builtinExtensions: [],
parameters: [],
forbidden: new Set(['forbidden']),
forbidden: new Set(['test/forbidden']),
}),
).toThrow(
"It is forbidden to override the following extension(s): 'forbidden', which is done by the following plugin(s): 'test'",
"It is forbidden to override the following extension(s): 'test/forbidden', which is done by the following plugin(s): 'test'",
);
});
@@ -271,7 +304,7 @@ describe('resolveAppNodeSpecs', () => {
expect(() =>
resolveAppNodeSpecs({
features: [
createExtensionOverrides({ extensions: [makeExt('forbidden')] }),
createExtensionOverrides({ extensions: [makeExtDef('forbidden')] }),
],
builtinExtensions: [],
parameters: [],
@@ -16,15 +16,17 @@
import { createExtension } from '@backstage/frontend-plugin-api';
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
const extBaseConfig = {
id: 'test',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
output: {},
factory: () => ({}),
};
const extension = createExtension(extBaseConfig);
const extension = resolveExtensionDefinition(
createExtension({
name: 'test',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
output: {},
factory: () => ({}),
}),
);
const baseSpec = {
extension,
@@ -36,7 +36,10 @@ describe('createApp', () => {
configLoader: async () =>
new MockConfigApi({
app: {
extensions: [{ 'themes.light': false }, { 'themes.dark': false }],
extensions: [
{ 'theme:app/light': false },
{ 'theme:app/dark': false },
],
},
}),
features: [
@@ -68,7 +71,6 @@ describe('createApp', () => {
id: duplicatedFeatureId,
extensions: [
createPageExtension({
id: 'test.page.first',
defaultPath: '/',
loader: async () => <div>First Page</div>,
}),
@@ -78,7 +80,6 @@ describe('createApp', () => {
id: duplicatedFeatureId,
extensions: [
createPageExtension({
id: 'test.page.last',
defaultPath: '/',
loader: async () => <div>Last Page</div>,
}),
@@ -106,7 +107,7 @@ describe('createApp', () => {
featureFlags: [{ name: 'test-1' }],
extensions: [
createExtension({
id: 'test.page.first',
name: 'first',
attachTo: { id: 'core', input: 'root' },
output: { element: coreExtensionData.reactElement },
factory() {
@@ -131,7 +132,8 @@ describe('createApp', () => {
featureFlags: [{ name: 'test-2' }],
extensions: [
createExtension({
id: 'core.router',
namespace: 'core',
name: 'router',
attachTo: { id: 'core', input: 'root' },
disabled: true,
output: {},
@@ -159,7 +161,6 @@ describe('createApp', () => {
id: 'my-plugin',
extensions: [
createPageExtension({
id: 'plugin.my-plugin.page',
defaultPath: '/',
loader: async () => {
const Component = () => {
@@ -182,32 +183,32 @@ describe('createApp', () => {
expect(String(tree.root)).toMatchInlineSnapshot(`
"<core out=[core.reactElement]>
root [
<core.router out=[core.reactElement]>
<core/router out=[core.reactElement]>
children [
<core.layout out=[core.reactElement]>
<core/layout out=[core.reactElement]>
content [
<core.routes out=[core.reactElement]>
<core/routes out=[core.reactElement]>
routes [
<plugin.my-plugin.page out=[core.routing.path, core.routing.ref, core.reactElement] />
<page:my-plugin out=[core.routing.path, core.routing.ref, core.reactElement] />
]
</core.routes>
</core/routes>
]
nav [
<core.nav out=[core.reactElement] />
<core/nav out=[core.reactElement] />
]
</core.layout>
</core/layout>
]
</core.router>
</core/router>
]
components [
<core.components.progress out=[component.ref] />
<core.components.errorBoundaryFallback out=[component.ref] />
<core.components.bootErrorPage out=[component.ref] />
<core.components.notFoundErrorPage out=[component.ref] />
<component:core.components.progress out=[component.ref] />
<component:core.components.errorBoundaryFallback out=[component.ref] />
<component:core.components.bootErrorPage out=[component.ref] />
<component:core.components.notFoundErrorPage out=[component.ref] />
]
themes [
<themes.light out=[core.theme] />
<themes.dark out=[core.theme] />
<theme:app/light out=[core.theme] />
<theme:app/dark out=[core.theme] />
]
</core>"
`);
@@ -73,6 +73,8 @@ import { AppLanguageSelector } from '../../../core-app-api/src/apis/implementati
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
apis as defaultApis,
components as defaultComponents,
@@ -119,7 +121,7 @@ export const builtinExtensions = [
DefaultNotFoundErrorPageComponent,
LightTheme,
DarkTheme,
];
].map(def => resolveExtensionDefinition(def));
/** @public */
export interface ExtensionTreeNode {
@@ -174,7 +176,7 @@ export function createExtensionTree(options: {
);
},
getRootRoutes(): JSX.Element[] {
return this.getExtensionAttachments('core.routes', 'routes').map(node => {
return this.getExtensionAttachments('core/routes', 'routes').map(node => {
const path = node.getData(coreExtensionData.routePath);
const element = node.getData(coreExtensionData.reactElement);
const routeRef = node.getData(coreExtensionData.routeRef);
@@ -201,7 +203,7 @@ export function createExtensionTree(options: {
);
};
return this.getExtensionAttachments('core.nav', 'items')
return this.getExtensionAttachments('core/nav', 'items')
.map((node, index) => {
const target = node.getData(coreExtensionData.navTarget);
if (!target) {
+58 -14
View File
@@ -392,7 +392,7 @@ export function createApiExtension<
configSchema?: PortableSchema<TConfig>;
inputs?: TInputs;
},
): Extension<TConfig>;
): ExtensionDefinition<TConfig>;
export { createApiFactory };
@@ -405,6 +405,7 @@ export function createComponentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
ref: TRef;
name?: string;
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
@@ -421,7 +422,7 @@ export function createComponentExtension<
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => TRef['T'];
};
}): Extension<TConfig>;
}): ExtensionDefinition<TConfig>;
// @public (undocumented)
export function createExtension<
@@ -430,7 +431,7 @@ export function createExtension<
TConfig = never,
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
): Extension<TConfig>;
): ExtensionDefinition<TConfig>;
// @public (undocumented)
export function createExtensionDataRef<TData>(
@@ -477,10 +478,14 @@ export interface CreateExtensionOptions<
inputs: Expand<ExtensionInputValues<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
id: string;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
@@ -516,11 +521,12 @@ export function createExternalRouteRef<
// @public
export function createNavItemExtension(options: {
id: string;
namespace?: string;
name?: string;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent_2;
}): Extension<{
}): ExtensionDefinition<{
title: string;
}>;
@@ -539,7 +545,8 @@ export function createPageExtension<
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
@@ -552,7 +559,7 @@ export function createPageExtension<
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig>;
): ExtensionDefinition<TConfig>;
// @public (undocumented)
export function createPlugin<
@@ -592,7 +599,8 @@ export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
@@ -604,7 +612,7 @@ export function createSignInPageExtension<
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): Extension<TConfig>;
}): ExtensionDefinition<TConfig>;
// @public
export function createSubRouteRef<
@@ -616,7 +624,9 @@ export function createSubRouteRef<
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
// @public (undocumented)
export function createThemeExtension(theme: AppTheme): Extension<never>;
export function createThemeExtension(
theme: AppTheme,
): ExtensionDefinition<never>;
export { DiscoveryApi };
@@ -703,6 +713,40 @@ export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
: never]?: TExtensionData[DataName]['T'];
};
// @public (undocumented)
export interface ExtensionDefinition<TConfig> {
// (undocumented)
$$type: '@backstage/ExtensionDefinition';
// (undocumented)
attachTo: {
id: string;
input: string;
};
// (undocumented)
configSchema?: PortableSchema<TConfig>;
// (undocumented)
disabled: boolean;
// (undocumented)
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): ExtensionDataValues<any>;
// (undocumented)
inputs: AnyExtensionInputMap;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: AnyExtensionDataMap;
}
// @public (undocumented)
export interface ExtensionInput<
TExtensionData extends AnyExtensionDataMap,
@@ -743,7 +787,7 @@ export interface ExtensionOverrides {
// @public (undocumented)
export interface ExtensionOverridesOptions {
// (undocumented)
extensions: Extension<unknown>[];
extensions: ExtensionDefinition<unknown>[];
// (undocumented)
featureFlags?: FeatureFlagConfig[];
}
@@ -841,7 +885,7 @@ export interface PluginOptions<
ExternalRoutes extends AnyExternalRoutes,
> {
// (undocumented)
extensions?: Extension<unknown>[];
extensions?: ExtensionDefinition<unknown>[];
// (undocumented)
externalRoutes?: ExternalRoutes;
// (undocumented)
@@ -24,11 +24,10 @@ import { createRouteRef } from '../routing';
import { createExtensionTester } from '@backstage/frontend-test-utils';
const wrapInBoundaryExtension = (element: JSX.Element) => {
const id = 'plugin.extension';
const routeRef = createRouteRef();
return createExtension({
id,
attachTo: { id: 'core.routes', input: 'routes' },
name: 'test',
attachTo: { id: 'core/routes', input: 'routes' },
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
@@ -84,15 +83,18 @@ describe('ExtensionBoundary', () => {
),
).render();
await waitFor(() =>
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
await waitFor(() => {
const event = analyticsApiMock
.getEvents()
.find(e => e.subject === subject);
expect(event).toMatchObject({
action,
subject,
context: {
extension: 'plugin.extension',
routeRef: 'unknown',
extensionId: 'test',
},
}),
);
});
});
});
});
@@ -59,7 +59,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
extension: node.spec.id,
extensionId: node.spec.id,
pluginId: node.spec.source?.id,
};
@@ -26,13 +26,14 @@ describe('createApiExtension', () => {
factory: () => ({ foo: 'bar' }),
});
const extension = createApiExtension({
factory,
});
expect(extension).toEqual({
$$type: '@backstage/Extension',
id: 'apis.test',
expect(
createApiExtension({
factory,
}),
).toEqual({
$$type: '@backstage/ExtensionDefinition',
kind: 'api',
namespace: 'test',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
configSchema: undefined,
@@ -65,8 +66,9 @@ describe('createApiExtension', () => {
});
// boo
expect(extension).toEqual({
$$type: '@backstage/Extension',
id: 'apis.test',
$$type: '@backstage/ExtensionDefinition',
kind: 'api',
namespace: 'test',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
configSchema: undefined,
@@ -51,7 +51,10 @@ export function createApiExtension<
'api' in options ? options.api : (factory as { api: AnyApiRef }).api;
return createExtension({
id: `apis.${apiRef.id}`,
kind: 'api',
// Since ApiRef IDs use a global namespace we use the namespace here in order to override
// potential plugin IDs and always end up with the format `api:<api-ref-id>`
namespace: apiRef.id,
attachTo: { id: 'core', input: 'apis' },
inputs: extensionInputs,
configSchema,
@@ -32,6 +32,7 @@ export function createComponentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
ref: TRef;
name?: string;
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
@@ -49,9 +50,10 @@ export function createComponentExtension<
}) => TRef['T'];
};
}) {
const id = options.ref.id;
return createExtension({
id,
kind: 'component',
namespace: options.ref.id,
name: options.name,
attachTo: { id: 'core', input: 'components' },
inputs: options.inputs,
disabled: options.disabled,
@@ -24,15 +24,18 @@ import { RouteRef } from '../routing';
* @public
*/
export function createNavItemExtension(options: {
id: string;
namespace?: string;
name?: string;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent;
}) {
const { id, routeRef, title, icon } = options;
const { routeRef, title, icon, namespace, name } = options;
return createExtension({
id,
attachTo: { id: 'core.nav', input: 'items' },
namespace,
name,
kind: 'nav-item',
attachTo: { id: 'core/nav', input: 'items' },
configSchema: createSchemaFromZod(z =>
z.object({
title: z.string().default(title),
@@ -25,14 +25,15 @@ describe('createNavLogoExtension', () => {
it('creates the extension properly', () => {
expect(
createNavLogoExtension({
id: 'test',
name: 'test',
logoFull: <div>Logo Full</div>,
logoIcon: <div>Logo Icon</div>,
}),
).toEqual({
$$type: '@backstage/Extension',
id: 'test',
attachTo: { id: 'core.nav', input: 'logos' },
$$type: '@backstage/ExtensionDefinition',
kind: 'nav-logo',
name: 'test',
attachTo: { id: 'core/nav', input: 'logos' },
disabled: false,
inputs: {},
output: {
@@ -21,14 +21,17 @@ import { coreExtensionData, createExtension } from '../wiring';
* @public
*/
export function createNavLogoExtension(options: {
id: string;
name?: string;
namespace?: string;
logoIcon: JSX.Element;
logoFull: JSX.Element;
}) {
const { id, logoIcon, logoFull } = options;
const { logoIcon, logoFull } = options;
return createExtension({
id,
attachTo: { id: 'core.nav', input: 'logos' },
kind: 'nav-logo',
name: options?.name,
namespace: options?.namespace,
attachTo: { id: 'core/nav', input: 'logos' },
output: {
logos: coreExtensionData.logoElements,
},
@@ -36,14 +36,15 @@ describe('createPageExtension', () => {
expect(
createPageExtension({
id: 'test',
name: 'test',
configSchema,
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/Extension',
id: 'test',
attachTo: { id: 'core.routes', input: 'routes' },
$$type: '@backstage/ExtensionDefinition',
name: 'test',
kind: 'page',
attachTo: { id: 'core/routes', input: 'routes' },
configSchema: expect.anything(),
disabled: false,
inputs: {},
@@ -57,7 +58,7 @@ describe('createPageExtension', () => {
expect(
createPageExtension({
id: 'test',
name: 'test',
attachTo: { id: 'other', input: 'place' },
disabled: true,
configSchema,
@@ -69,8 +70,9 @@ describe('createPageExtension', () => {
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/Extension',
id: 'test',
$$type: '@backstage/ExtensionDefinition',
name: 'test',
kind: 'page',
attachTo: { id: 'other', input: 'place' },
configSchema: expect.anything(),
disabled: true,
@@ -89,14 +91,15 @@ describe('createPageExtension', () => {
expect(
createPageExtension({
id: 'test',
name: 'test',
defaultPath: '/here',
loader: async () => <div />,
}),
).toEqual({
$$type: '@backstage/Extension',
id: 'test',
attachTo: { id: 'core.routes', input: 'routes' },
$$type: '@backstage/ExtensionDefinition',
name: 'test',
kind: 'page',
attachTo: { id: 'core/routes', input: 'routes' },
configSchema: expect.anything(),
disabled: false,
inputs: {},
@@ -118,7 +121,6 @@ describe('createPageExtension', () => {
createExtensionTester(
createPageExtension({
id: 'plugin.page',
defaultPath: '/',
loader: async () => <div>Component</div>,
}),
@@ -20,12 +20,12 @@ import { createSchemaFromZod, PortableSchema } from '../schema';
import {
coreExtensionData,
createExtension,
Extension,
ExtensionInputValues,
AnyExtensionInputMap,
} from '../wiring';
import { RouteRef } from '../routing';
import { Expand } from '../types';
import { ExtensionDefinition } from '../wiring/createExtension';
/**
* Helper for creating extensions for a routable React page component.
@@ -44,7 +44,8 @@ export function createPageExtension<
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
@@ -54,9 +55,7 @@ export function createPageExtension<
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
const { id } = options;
): ExtensionDefinition<TConfig> {
const configSchema =
'configSchema' in options
? options.configSchema
@@ -65,8 +64,10 @@ export function createPageExtension<
) as PortableSchema<TConfig>);
return createExtension({
id,
attachTo: options.attachTo ?? { id: 'core.routes', input: 'routes' },
kind: 'page',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? { id: 'core/routes', input: 'routes' },
configSchema,
inputs: options.inputs,
disabled: options.disabled,
@@ -23,13 +23,13 @@ import { coreExtensionData, createExtension } from '../wiring';
describe('createSignInPageExtension', () => {
it('renders a sign-in page', async () => {
const SignInPage = createSignInPageExtension({
id: 'test',
name: 'test',
loader: async () => () => <div data-testid="sign-in-page" />,
});
createExtensionTester(
createExtension({
id: 'dummy',
name: 'dummy',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
element: coreExtensionData.reactElement,
@@ -19,10 +19,10 @@ import { ExtensionBoundary } from '../components';
import { PortableSchema } from '../schema';
import {
createExtension,
Extension,
ExtensionInputValues,
AnyExtensionInputMap,
createExtensionDataRef,
ExtensionDefinition,
} from '../wiring';
import { Expand } from '../types';
import { SignInPageProps } from '@backstage/core-plugin-api';
@@ -39,7 +39,8 @@ export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
@@ -48,12 +49,12 @@ export function createSignInPageExtension<
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): Extension<TConfig> {
const { id } = options;
}): ExtensionDefinition<TConfig> {
return createExtension({
id,
attachTo: options.attachTo ?? { id: 'core.router', input: 'signInPage' },
kind: 'sign-in-page',
namespace: options?.namespace,
name: options?.name,
attachTo: options.attachTo ?? { id: 'core/router', input: 'signInPage' },
configSchema: options.configSchema,
inputs: options.inputs,
disabled: options.disabled,
@@ -20,7 +20,9 @@ import { AppTheme } from '@backstage/core-plugin-api';
/** @public */
export function createThemeExtension(theme: AppTheme) {
return createExtension({
id: `themes.${theme.id}`,
kind: 'theme',
namespace: 'app',
name: theme.id,
attachTo: { id: 'core', input: 'themes' },
output: {
theme: coreExtensionData.theme,
@@ -25,7 +25,7 @@ function unused(..._any: any[]) {}
describe('createExtension', () => {
it('should create an extension with a simple output', () => {
const baseConfig = {
id: 'test',
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
@@ -39,7 +39,7 @@ describe('createExtension', () => {
};
},
});
expect(extension.id).toBe('test');
expect(extension.namespace).toBe('test');
// When declared as an error function without a block the TypeScript errors
// are a more specific and will point at the property that is problematic.
@@ -163,7 +163,7 @@ describe('createExtension', () => {
it('should create an extension with a some optional output', () => {
const baseConfig = {
id: 'test',
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
@@ -176,7 +176,7 @@ describe('createExtension', () => {
foo: 'bar',
}),
});
expect(extension.id).toBe('test');
expect(extension.namespace).toBe('test');
createExtension({
...baseConfig,
@@ -233,7 +233,7 @@ describe('createExtension', () => {
it('should create an extension with input', () => {
const extension = createExtension({
id: 'test',
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
inputs: {
mixed: createExtensionInput({
@@ -286,6 +286,6 @@ describe('createExtension', () => {
};
},
});
expect(extension.id).toBe('test');
expect(extension.namespace).toBe('test');
});
});
@@ -73,7 +73,9 @@ export interface CreateExtensionOptions<
TInputs extends AnyExtensionInputMap,
TConfig,
> {
id: string;
kind?: string;
namespace?: string;
name?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
@@ -86,6 +88,27 @@ export interface CreateExtensionOptions<
}): Expand<ExtensionDataValues<TOutput>>;
}
/** @public */
export interface ExtensionDefinition<TConfig> {
$$type: '@backstage/ExtensionDefinition';
kind?: string;
namespace?: string;
name?: string;
attachTo: { id: string; input: string };
disabled: boolean;
inputs: AnyExtensionInputMap;
output: AnyExtensionDataMap;
configSchema?: PortableSchema<TConfig>;
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): ExtensionDataValues<any>;
}
/** @public */
export interface Extension<TConfig> {
$$type: '@backstage/Extension';
@@ -112,12 +135,17 @@ export function createExtension<
TConfig = never,
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
): Extension<TConfig> {
): ExtensionDefinition<TConfig> {
return {
...options,
$$type: '@backstage/ExtensionDefinition',
kind: options.kind,
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo,
disabled: options.disabled ?? false,
$$type: '@backstage/Extension',
inputs: options.inputs ?? {},
output: options.output,
configSchema: options.configSchema,
factory({ inputs, ...rest }) {
// TODO: Simplify this, but TS wouldn't infer the input type for some reason
return options.factory({
@@ -37,7 +37,21 @@ describe('createExtensionOverrides', () => {
createExtensionOverrides({
extensions: [
createExtension({
id: 'a',
name: 'a',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory: () => ({}),
}),
createExtension({
namespace: 'b',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory: () => ({}),
}),
createExtension({
kind: 'k',
namespace: 'c',
name: 'n',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory: () => ({}),
@@ -54,12 +68,39 @@ describe('createExtensionOverrides', () => {
"id": "core",
"input": "apis",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"id": "a",
"inputs": {},
"output": {},
},
{
"$$type": "@backstage/Extension",
"attachTo": {
"id": "core",
"input": "apis",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"id": "b",
"inputs": {},
"output": {},
},
{
"$$type": "@backstage/Extension",
"attachTo": {
"id": "core",
"input": "apis",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"id": "k:c/n",
"inputs": {},
"output": {},
},
],
"featureFlags": [],
"version": "v1",
@@ -71,7 +112,7 @@ describe('createExtensionOverrides', () => {
const overrides = createExtensionOverrides({
extensions: [
createExtension({
id: 'a',
namespace: 'a',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory: () => ({}),
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import { Extension } from './createExtension';
import { Extension, ExtensionDefinition } from './createExtension';
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
/** @public */
export interface ExtensionOverridesOptions {
extensions: Extension<unknown>[];
extensions: ExtensionDefinition<unknown>[];
featureFlags?: FeatureFlagConfig[];
}
@@ -42,7 +43,7 @@ export function createExtensionOverrides(
return {
$$type: '@backstage/ExtensionOverrides',
version: 'v1',
extensions: options.extensions,
extensions: options.extensions.map(def => resolveExtensionDefinition(def)),
featureFlags: options.featureFlags ?? [],
} as InternalExtensionOverrides;
}
@@ -28,48 +28,34 @@ import { createExtensionInput } from './createExtensionInput';
const nameExtensionDataRef = createExtensionDataRef<string>('name');
const TechRadarPage = createExtension({
id: 'plugin.techradar.page',
attachTo: { id: 'test.output', input: 'names' },
const Extension1 = createExtension({
name: '1',
attachTo: { id: 'test/output', input: 'names' },
output: {
name: nameExtensionDataRef,
},
factory() {
return { name: 'TechRadar' };
return { name: 'extension-1' };
},
});
const CatalogPage = createExtension({
id: 'plugin.catalog.page',
attachTo: { id: 'test.output', input: 'names' },
const Extension2 = createExtension({
name: '2',
attachTo: { id: 'test/output', input: 'names' },
output: {
name: nameExtensionDataRef,
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('Catalog') }),
z.object({ name: z.string().default('extension-2') }),
),
factory({ config }) {
return { name: config.name };
},
});
const TechDocsAddon = createExtension({
id: 'plugin.techdocs.addon.example',
attachTo: { id: 'plugin.techdocs.page', input: 'addons' },
output: {
name: nameExtensionDataRef,
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('TechDocsAddon') }),
),
factory({ config }) {
return { name: config.name };
},
});
const TechDocsPage = createExtension({
id: 'plugin.techdocs.page',
attachTo: { id: 'test.output', input: 'names' },
const Extension3 = createExtension({
name: '3',
attachTo: { id: 'test/output', input: 'names' },
inputs: {
addons: createExtensionInput({
name: nameExtensionDataRef,
@@ -79,12 +65,40 @@ const TechDocsPage = createExtension({
name: nameExtensionDataRef,
},
factory({ inputs }) {
return { name: `TechDocs-${inputs.addons.map(n => n.name).join('-')}` };
return { name: `extension-3:${inputs.addons.map(n => n.name).join('-')}` };
},
});
const Child = createExtension({
name: 'child',
attachTo: { id: 'test/3', input: 'addons' },
output: {
name: nameExtensionDataRef,
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('child') }),
),
factory({ config }) {
return { name: config.name };
},
});
const Child2 = createExtension({
name: 'child2',
attachTo: { id: 'test/3', input: 'addons' },
output: {
name: nameExtensionDataRef,
},
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('child2') }),
),
factory({ config }) {
return { name: config.name };
},
});
const outputExtension = createExtension({
id: 'test.output',
name: 'output',
attachTo: { id: 'core', input: 'root' },
inputs: {
names: createExtensionInput({
@@ -118,40 +132,34 @@ function createTestAppRoot({
describe('createPlugin', () => {
it('should create an empty plugin', () => {
const plugin = createPlugin({ id: 'empty' });
const plugin = createPlugin({ id: 'test' });
expect(plugin).toBeDefined();
});
it('should create a plugin with extension instances', async () => {
const plugin = createPlugin({
id: 'empty',
extensions: [TechRadarPage, CatalogPage, outputExtension],
id: 'test',
extensions: [Extension1, Extension2, outputExtension],
});
expect(plugin).toBeDefined();
await renderWithEffects(
createTestAppRoot({
features: [plugin],
config: { app: { extensions: [{ 'core.router': false }] } },
config: { app: { extensions: [{ 'core/router': false }] } },
}),
);
await expect(
screen.findByText('Names: TechRadar, Catalog'),
screen.findByText('Names: extension-1, extension-2'),
).resolves.toBeInTheDocument();
});
it('should create a plugin with nested extension instances', async () => {
const plugin = createPlugin({
id: 'empty',
extensions: [
TechRadarPage,
CatalogPage,
TechDocsPage,
TechDocsAddon,
outputExtension,
],
id: 'test',
extensions: [Extension1, Extension2, Extension3, Child, outputExtension],
});
expect(plugin).toBeDefined();
@@ -161,10 +169,10 @@ describe('createPlugin', () => {
config: {
app: {
extensions: [
{ 'core.router': false },
{ 'core/router': false },
{
'plugin.catalog.page': {
config: { name: 'CatalogRenamed' },
'test/2': {
config: { name: 'extension-2-renamed' },
},
},
],
@@ -175,8 +183,63 @@ describe('createPlugin', () => {
await expect(
screen.findByText(
'Names: TechRadar, CatalogRenamed, TechDocs-TechDocsAddon',
'Names: extension-1, extension-2-renamed, extension-3:child',
),
).resolves.toBeInTheDocument();
});
it('should create a plugin with nested extension instances and multiple children', async () => {
const plugin = createPlugin({
id: 'test',
extensions: [
Extension1,
Extension2,
Extension3,
Child,
Child2,
outputExtension,
],
});
expect(plugin).toBeDefined();
await renderWithEffects(
createTestAppRoot({
features: [plugin],
config: {
app: {
extensions: [{ 'core/router': false }],
},
},
}),
);
await expect(
screen.findByText(
'Names: extension-1, extension-2, extension-3:child-child2',
),
).resolves.toBeInTheDocument();
});
it('should throw on duplicate extensions', async () => {
expect(() =>
createPlugin({
id: 'test',
extensions: [Extension1, Extension1],
}),
).toThrow("Plugin 'test' provided duplicate extensions: test/1");
expect(() =>
createPlugin({
id: 'test',
extensions: [
Extension1,
Extension2,
Extension2,
Extension3,
Extension3,
Extension3,
],
}),
).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3");
});
});
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { Extension } from './createExtension';
import { Extension, ExtensionDefinition } from './createExtension';
import { ExternalRouteRef, RouteRef } from '../routing';
import { FeatureFlagConfig } from './types';
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
/** @public */
export type AnyRoutes = { [name in string]: RouteRef };
@@ -32,7 +33,7 @@ export interface PluginOptions<
id: string;
routes?: Routes;
externalRoutes?: ExternalRoutes;
extensions?: Extension<unknown>[];
extensions?: ExtensionDefinition<unknown>[];
featureFlags?: FeatureFlagConfig[];
}
@@ -64,14 +65,33 @@ export function createPlugin<
>(
options: PluginOptions<Routes, ExternalRoutes>,
): BackstagePlugin<Routes, ExternalRoutes> {
const extensions = (options.extensions ?? []).map(def =>
resolveExtensionDefinition(def, { namespace: options.id }),
);
const extensionIds = extensions.map(e => e.id);
if (extensionIds.length !== new Set(extensionIds).size) {
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
),
);
// TODO(Rugvip): This could provide some more information about the kind + name of the extensions
throw new Error(
`Plugin '${options.id}' provided duplicate extensions: ${duplicates.join(
', ',
)}`,
);
}
return {
$$type: '@backstage/BackstagePlugin',
version: 'v1',
id: options.id,
routes: options.routes ?? ({} as Routes),
externalRoutes: options.externalRoutes ?? ({} as ExternalRoutes),
extensions: options.extensions ?? [],
featureFlags: options.featureFlags ?? [],
extensions,
} as InternalBackstagePlugin<Routes, ExternalRoutes>;
}
@@ -22,6 +22,7 @@ export {
export {
createExtension,
type Extension,
type ExtensionDefinition,
type CreateExtensionOptions,
type ExtensionDataValues,
type ExtensionInputValues,
@@ -0,0 +1,48 @@
/*
* Copyright 2023 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 { ExtensionDefinition } from './createExtension';
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
describe('resolveExtensionDefinition', () => {
it.each([
[{ namespace: 'ns' }, 'ns'],
[{ namespace: 'n' }, 'n'],
[{ namespace: 'ns', name: 'n' }, 'ns/n'],
[{ kind: 'k', namespace: 'ns' }, 'k:ns'],
[{ kind: 'k', namespace: 'ns', name: 'n' }, 'k:ns/n'],
])(`should resolve extension IDs %s`, (definition, expected) => {
const resolved = resolveExtensionDefinition(
definition as ExtensionDefinition<unknown>,
);
expect(resolved.id).toBe(expected);
});
it('should fail to resolve extension ID without namespace', () => {
expect(() =>
resolveExtensionDefinition({
kind: 'k',
} as ExtensionDefinition<unknown>),
).toThrow(
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=k namespace=undefined name=undefined',
);
expect(() =>
resolveExtensionDefinition({} as ExtensionDefinition<unknown>),
).toThrow(
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined',
);
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2023 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 { Extension, ExtensionDefinition } from './createExtension';
/** @internal */
export function resolveExtensionDefinition<TConfig>(
definition: ExtensionDefinition<TConfig>,
context?: { namespace?: string },
): Extension<TConfig> {
const { name, kind, namespace: _, ...rest } = definition;
const namespace = definition.namespace ?? context?.namespace;
const namePart =
name && namespace ? `${namespace}/${name}` : namespace || name;
if (!namePart) {
throw new Error(
`Extension must declare an explicit namespace or name as it could not be resolved from context, kind=${kind} namespace=${namespace} name=${name}`,
);
}
return {
...rest,
id: kind ? `${kind}:${namePart}` : namePart,
$$type: '@backstage/Extension',
};
}
+3 -3
View File
@@ -4,7 +4,7 @@
```ts
import { ErrorWithContext } from '@backstage/test-utils';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { MockAnalyticsApi } from '@backstage/test-utils';
import { MockConfigApi } from '@backstage/test-utils';
@@ -24,7 +24,7 @@ import { withLogCollector } from '@backstage/test-utils';
// @public (undocumented)
export function createExtensionTester<TConfig>(
subject: Extension<TConfig>,
subject: ExtensionDefinition<TConfig>,
options?: {
config?: TConfig;
},
@@ -36,7 +36,7 @@ export { ErrorWithContext };
export class ExtensionTester {
// (undocumented)
add<TConfig>(
extension: Extension<TConfig>,
extension: ExtensionDefinition<TConfig>,
options?: {
config?: TConfig;
},
@@ -26,7 +26,7 @@ describe('createExtensionTester', () => {
it('should render a simple extension', async () => {
createExtensionTester(
createExtension({
id: 'test',
namespace: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
output: { element: coreExtensionData.reactElement },
factory: () => ({ element: <div>test</div> }),
@@ -39,7 +39,7 @@ describe('createExtensionTester', () => {
it('should render an extension even if disabled by default', async () => {
createExtensionTester(
createExtension({
id: 'test',
namespace: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
disabled: true,
output: { element: coreExtensionData.reactElement },
@@ -54,7 +54,7 @@ describe('createExtensionTester', () => {
expect(() =>
createExtensionTester(
createExtension({
id: 'test',
namespace: 'test',
attachTo: { id: 'ignored', input: 'ignored' },
disabled: true,
output: { path: coreExtensionData.routePath },
@@ -62,7 +62,7 @@ describe('createExtensionTester', () => {
}),
).render(),
).toThrow(
"Failed to instantiate extension 'core.router', input 'children' did not receive required extension data 'core.reactElement' from extension 'test'",
"Failed to instantiate extension 'core/router', input 'children' did not receive required extension data 'core.reactElement' from extension 'test'",
);
});
});
@@ -15,7 +15,12 @@
*/
import { createSpecializedApp } from '@backstage/frontend-app-api';
import { Extension, createPlugin } from '@backstage/frontend-plugin-api';
import {
ExtensionDefinition,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { MockConfigApi } from '@backstage/test-utils';
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
import { RenderResult, render } from '@testing-library/react';
@@ -24,7 +29,7 @@ import { RenderResult, render } from '@testing-library/react';
export class ExtensionTester {
/** @internal */
static forSubject<TConfig>(
subject: Extension<TConfig>,
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const tester = new ExtensionTester();
@@ -33,16 +38,22 @@ export class ExtensionTester {
}
readonly #extensions = new Array<{
extension: Extension<any>;
id: string;
extension: ExtensionDefinition<any>;
config?: JsonValue;
}>();
add<TConfig>(
extension: Extension<TConfig>,
extension: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const withNamespace = {
...extension,
name: !extension.namespace && !extension.name ? 'test' : extension.name,
};
this.#extensions.push({
extension,
id: resolveExtensionDefinition(withNamespace).id,
extension: withNamespace,
config: options?.config as JsonValue,
});
@@ -61,25 +72,25 @@ export class ExtensionTester {
const extensionsConfig: JsonArray = [
...rest.map(entry => ({
[entry.extension.id]: {
[entry.id]: {
config: entry.config,
},
})),
{
[subject.extension.id]: {
attachTo: { id: 'core.router', input: 'children' },
[subject.id]: {
attachTo: { id: 'core/router', input: 'children' },
config: subject.config,
disabled: false,
},
},
{
'core.layout': false,
'core/layout': false,
},
{
'core.nav': false,
'core/nav': false,
},
{
'core.routes': false,
'core/routes': false,
},
];
@@ -93,8 +104,7 @@ export class ExtensionTester {
const app = createSpecializedApp({
features: [
createPlugin({
id: 'test',
createExtensionOverrides({
extensions: this.#extensions.map(entry => entry.extension),
}),
],
@@ -107,7 +117,7 @@ export class ExtensionTester {
/** @public */
export function createExtensionTester<TConfig>(
subject: Extension<TConfig>,
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
return ExtensionTester.forSubject(subject, options);
+2 -2
View File
@@ -4,11 +4,11 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const AdrSearchResultListItemExtension: Extension<{
export const AdrSearchResultListItemExtension: ExtensionDefinition<{
lineClamp: number;
noTrack: boolean;
}>;
-1
View File
@@ -31,7 +31,6 @@ function isAdrDocument(result: any): result is AdrDocument {
/** @alpha */
export const AdrSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'adr',
configSchema: createSchemaFromZod(z =>
z.object({
// TODO: Define how the icon can be configurable
-1
View File
@@ -38,7 +38,6 @@ import { catalogApiRef } from '@backstage/plugin-catalog-react';
// TODO: It's currently possible to override the import page with a custom one. We need to decide
// whether this type of override is typically done with an input or by overriding the entire extension.
const CatalogImportPageExtension = createPageExtension({
id: 'plugin.catalog-import.page',
defaultPath: '/catalog-import',
routeRef: convertLegacyRouteRef(rootRouteRef),
loader: () => import('./components/ImportPage').then(m => <m.ImportPage />),
+7 -5
View File
@@ -8,7 +8,7 @@
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInputValues } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
@@ -17,7 +17,8 @@ import { RouteRef } from '@backstage/frontend-plugin-api';
export function createEntityCardExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
@@ -30,7 +31,7 @@ export function createEntityCardExtension<
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<{
}): ExtensionDefinition<{
filter?: string | undefined;
}>;
@@ -38,7 +39,8 @@ export function createEntityCardExtension<
export function createEntityContentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
@@ -54,7 +56,7 @@ export function createEntityContentExtension<
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<{
}): ExtensionDefinition<{
title: string;
path: string;
filter?: string | undefined;
+12 -10
View File
@@ -50,7 +50,8 @@ export const entityFilterExpressionExtensionDataRef =
export function createEntityCardExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
@@ -61,12 +62,12 @@ export function createEntityCardExtension<
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}) {
const id = `entity.cards.${options.id}`;
return createExtension({
id,
kind: 'entity-card',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'entity.content.overview',
id: 'entity-content:catalog/overview',
input: 'cards',
},
disabled: options.disabled ?? true,
@@ -104,7 +105,8 @@ export function createEntityCardExtension<
export function createEntityContentExtension<
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
@@ -118,12 +120,12 @@ export function createEntityContentExtension<
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}) {
const id = `entity.content.${options.id}`;
return createExtension({
id,
kind: 'entity-content',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'plugin.catalog.page.entity',
id: 'page:catalog/entity',
input: 'contents',
},
disabled: options.disabled ?? true,
+4 -3
View File
@@ -7,7 +7,7 @@
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
@@ -17,11 +17,12 @@ export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(options: {
id: string;
namespace?: string;
name?: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}): Extension<TConfig>;
}): ExtensionDefinition<TConfig>;
// @alpha (undocumented)
const _default: BackstagePlugin<
@@ -28,16 +28,17 @@ export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(options: {
id: string;
namespace?: string;
name?: string;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: { config: TConfig }) => Promise<JSX.Element>;
}) {
const id = `catalog.filter.${options.id}`;
return createExtension({
id,
attachTo: { id: 'plugin.catalog.page.index', input: 'filters' },
kind: 'catalog-filter',
namespace: options.namespace,
name: options.name,
attachTo: { id: 'page:catalog', input: 'filters' },
inputs: options.inputs ?? {},
configSchema: options.configSchema,
output: {
+9 -9
View File
@@ -18,7 +18,7 @@ import React from 'react';
import { createEntityCardExtension } from '@backstage/plugin-catalog-react/alpha';
export const EntityAboutCard = createEntityCardExtension({
id: 'about',
name: 'about',
loader: async () =>
import('../components/AboutCard').then(m => (
<m.AboutCard variant="gridItem" />
@@ -26,7 +26,7 @@ export const EntityAboutCard = createEntityCardExtension({
});
export const EntityLinksCard = createEntityCardExtension({
id: 'links',
name: 'links',
filter: 'has:links',
loader: async () =>
import('../components/EntityLinksCard').then(m => {
@@ -35,7 +35,7 @@ export const EntityLinksCard = createEntityCardExtension({
});
export const EntityLabelsCard = createEntityCardExtension({
id: 'labels',
name: 'labels',
filter: 'has:labels',
loader: async () =>
import('../components/EntityLabelsCard').then(m => (
@@ -44,7 +44,7 @@ export const EntityLabelsCard = createEntityCardExtension({
});
export const EntityDependsOnComponentsCard = createEntityCardExtension({
id: 'dependsOn.components',
name: 'depends-on-components',
loader: async () =>
import('../components/DependsOnComponentsCard').then(m => (
<m.DependsOnComponentsCard variant="gridItem" />
@@ -52,7 +52,7 @@ export const EntityDependsOnComponentsCard = createEntityCardExtension({
});
export const EntityDependsOnResourcesCard = createEntityCardExtension({
id: 'dependsOn.resources',
name: 'depends-on-resources',
loader: async () =>
import('../components/DependsOnResourcesCard').then(m => (
<m.DependsOnResourcesCard variant="gridItem" />
@@ -60,7 +60,7 @@ export const EntityDependsOnResourcesCard = createEntityCardExtension({
});
export const EntityHasComponentsCard = createEntityCardExtension({
id: 'has.components',
name: 'has-components',
loader: async () =>
import('../components/HasComponentsCard').then(m => (
<m.HasComponentsCard variant="gridItem" />
@@ -68,7 +68,7 @@ export const EntityHasComponentsCard = createEntityCardExtension({
});
export const EntityHasResourcesCard = createEntityCardExtension({
id: 'has.resources',
name: 'has-resources',
loader: async () =>
import('../components/HasResourcesCard').then(m => (
<m.HasResourcesCard variant="gridItem" />
@@ -76,7 +76,7 @@ export const EntityHasResourcesCard = createEntityCardExtension({
});
export const EntityHasSubcomponentsCard = createEntityCardExtension({
id: 'has.subcomponents',
name: 'has-subcomponents',
loader: async () =>
import('../components/HasSubcomponentsCard').then(m => (
<m.HasSubcomponentsCard variant="gridItem" />
@@ -84,7 +84,7 @@ export const EntityHasSubcomponentsCard = createEntityCardExtension({
});
export const EntityHasSystemsCard = createEntityCardExtension({
id: 'has.systems',
name: 'has-systems',
loader: async () =>
import('../components/HasSystemsCard').then(m => (
<m.HasSystemsCard variant="gridItem" />
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} from '@backstage/plugin-catalog-react/alpha';
export const OverviewEntityContent = createEntityContentExtension({
id: 'overview',
name: 'overview',
defaultPath: '/',
defaultTitle: 'Overview',
disabled: false,
+8 -8
View File
@@ -19,7 +19,7 @@ import { createCatalogFilterExtension } from './createCatalogFilterExtension';
import { createSchemaFromZod } from '@backstage/frontend-plugin-api';
const CatalogEntityTagFilter = createCatalogFilterExtension({
id: 'entity.tag',
name: 'tag',
loader: async () => {
const { EntityTagPicker } = await import('@backstage/plugin-catalog-react');
return <EntityTagPicker />;
@@ -27,7 +27,7 @@ const CatalogEntityTagFilter = createCatalogFilterExtension({
});
const CatalogEntityKindFilter = createCatalogFilterExtension({
id: 'entity.kind',
name: 'kind',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.string().default('component'),
@@ -42,7 +42,7 @@ const CatalogEntityKindFilter = createCatalogFilterExtension({
});
const CatalogEntityTypeFilter = createCatalogFilterExtension({
id: 'entity.type',
name: 'type',
loader: async () => {
const { EntityTypePicker } = await import(
'@backstage/plugin-catalog-react'
@@ -52,7 +52,7 @@ const CatalogEntityTypeFilter = createCatalogFilterExtension({
});
const CatalogEntityOwnerFilter = createCatalogFilterExtension({
id: 'entity.mode',
name: 'mode',
configSchema: createSchemaFromZod(z =>
z.object({
mode: z.enum(['owners-only', 'all']).optional(),
@@ -67,7 +67,7 @@ const CatalogEntityOwnerFilter = createCatalogFilterExtension({
});
const CatalogEntityNamespaceFilter = createCatalogFilterExtension({
id: 'entity.namespace',
name: 'namespace',
loader: async () => {
const { EntityNamespacePicker } = await import(
'@backstage/plugin-catalog-react'
@@ -77,7 +77,7 @@ const CatalogEntityNamespaceFilter = createCatalogFilterExtension({
});
const CatalogEntityLifecycleFilter = createCatalogFilterExtension({
id: 'entity.lifecycle',
name: 'lifecycle',
loader: async () => {
const { EntityLifecyclePicker } = await import(
'@backstage/plugin-catalog-react'
@@ -87,7 +87,7 @@ const CatalogEntityLifecycleFilter = createCatalogFilterExtension({
});
const CatalogEntityProcessingStatusFilter = createCatalogFilterExtension({
id: 'entity.processing.status',
name: 'processing.status',
loader: async () => {
const { EntityProcessingStatusPicker } = await import(
'@backstage/plugin-catalog-react'
@@ -97,7 +97,7 @@ const CatalogEntityProcessingStatusFilter = createCatalogFilterExtension({
});
const CatalogUserListFilter = createCatalogFilterExtension({
id: 'user.list',
name: 'list',
configSchema: createSchemaFromZod(z =>
z.object({
initialFilter: z.enum(['owned', 'starred', 'all']).default('owned'),
-1
View File
@@ -20,7 +20,6 @@ import { createNavItemExtension } from '@backstage/frontend-plugin-api';
import { rootRouteRef } from '../routes';
export const CatalogIndexNavItem = createNavItemExtension({
id: 'catalog.nav.index',
routeRef: convertLegacyRouteRef(rootRouteRef),
title: 'Catalog',
icon: HomeIcon,
+1 -2
View File
@@ -30,7 +30,6 @@ import { rootRouteRef } from '../routes';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
export const CatalogIndexPage = createPageExtension({
id: 'plugin.catalog.page.index',
defaultPath: '/catalog',
routeRef: convertLegacyRouteRef(rootRouteRef),
inputs: {
@@ -46,7 +45,7 @@ export const CatalogIndexPage = createPageExtension({
});
export const CatalogEntityPage = createPageExtension({
id: 'plugin.catalog.page.entity',
name: 'entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
inputs: {
@@ -18,7 +18,6 @@ import { createSearchResultListItemExtension } from '@backstage/plugin-search-re
export const CatalogSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'catalog',
predicate: result => result.type === 'software-catalog',
component: () =>
import('../components/CatalogSearchResultListItem').then(
+2 -2
View File
@@ -4,14 +4,14 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<{}, {}>;
export default _default;
// @alpha (undocumented)
export const ExploreSearchResultListItemExtension: Extension<{
export const ExploreSearchResultListItemExtension: ExtensionDefinition<{
noTrack?: boolean | undefined;
}>;
-1
View File
@@ -20,7 +20,6 @@ import { createSearchResultListItemExtension } from '@backstage/plugin-search-re
/** @alpha */
export const ExploreSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'explore',
predicate: result => result.type === 'tools',
component: () =>
import('./components/ToolSearchResultListItem').then(
+8 -7
View File
@@ -4,20 +4,21 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { GraphQLEndpoint } from '@backstage/plugin-graphiql';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export function createEndpointExtension<TConfig extends {}>(options: {
id: string;
export function createGraphiQLEndpointExtension<TConfig extends {}>(options: {
namespace?: string;
name?: string;
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
factory: (options: { config: TConfig }) => {
endpoint: GraphQLEndpoint;
};
}): Extension<TConfig>;
}): ExtensionDefinition<TConfig>;
// @alpha (undocumented)
const _default: BackstagePlugin<
@@ -29,15 +30,15 @@ const _default: BackstagePlugin<
export default _default;
// @alpha (undocumented)
export const graphiqlBrowseApi: Extension<{}>;
export const graphiqlBrowseApi: ExtensionDefinition<{}>;
// @alpha (undocumented)
export const GraphiqlPage: Extension<{
export const GraphiqlPage: ExtensionDefinition<{
path: string;
}>;
// @alpha (undocumented)
export const graphiqlPageSidebarItem: Extension<{
export const graphiqlPageSidebarItem: ExtensionDefinition<{
title: string;
}>;
+10 -9
View File
@@ -38,7 +38,6 @@ import { convertLegacyRouteRef } from '@backstage/core-compat-api';
/** @alpha */
export const GraphiqlPage = createPageExtension({
id: 'plugin.graphiql.page',
defaultPath: '/graphiql',
routeRef: convertLegacyRouteRef(graphiQLRouteRef),
loader: () => import('./components').then(m => <m.GraphiQLPage />),
@@ -46,7 +45,6 @@ export const GraphiqlPage = createPageExtension({
/** @alpha */
export const graphiqlPageSidebarItem = createNavItemExtension({
id: 'plugin.graphiql.nav.index',
title: 'GraphiQL',
icon: GraphiQLIcon as IconComponent,
routeRef: convertLegacyRouteRef(graphiQLRouteRef),
@@ -59,7 +57,7 @@ const endpointDataRef = createExtensionDataRef<GraphQLEndpoint>(
/** @alpha */
export const graphiqlBrowseApi = createApiExtension({
api: graphQlBrowseApiRef, // apis.plugin.graphiql.browse
api: graphQlBrowseApiRef,
inputs: {
endpoints: createExtensionInput({
endpoint: endpointDataRef,
@@ -74,15 +72,18 @@ export const graphiqlBrowseApi = createApiExtension({
});
/** @alpha */
export function createEndpointExtension<TConfig extends {}>(options: {
id: string;
export function createGraphiQLEndpointExtension<TConfig extends {}>(options: {
namespace?: string;
name?: string;
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
factory: (options: { config: TConfig }) => { endpoint: GraphQLEndpoint };
}) {
return createExtension({
id: `apis.plugin.graphiql.browse.${options.id}`,
attachTo: { id: 'apis.plugin.graphiql.browse', input: 'endpoints' },
kind: 'graphiql-endpoint',
namespace: options.namespace,
name: options.name,
attachTo: { id: 'api:graphiql/browse', input: 'endpoints' },
configSchema: options.configSchema,
disabled: options.disabled ?? false,
output: {
@@ -97,8 +98,8 @@ export function createEndpointExtension<TConfig extends {}>(options: {
}
/** @alpha */
const gitlabGraphiQLBrowseExtension = createEndpointExtension({
id: 'gitlab',
const gitlabGraphiQLBrowseExtension = createGraphiQLEndpointExtension({
name: 'gitlab',
disabled: true,
configSchema: createSchemaFromZod(z =>
z
-1
View File
@@ -33,7 +33,6 @@ const rootRouteRef = createRouteRef();
export const titleExtensionDataRef = createExtensionDataRef<string>('title');
const HomepageCompositionRootExtension = createPageExtension({
id: 'home',
defaultPath: '/home',
routeRef: rootRouteRef,
inputs: {
+6 -3
View File
@@ -6,7 +6,7 @@
/// <reference types="react" />
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ListItemProps } from '@material-ui/core';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { SearchDocument } from '@backstage/plugin-search-common';
@@ -23,7 +23,9 @@ export function createSearchResultListItemExtension<
TConfig extends {
noTrack?: boolean;
},
>(options: SearchResultItemExtensionOptions<TConfig>): Extension<TConfig>;
>(
options: SearchResultItemExtensionOptions<TConfig>,
): ExtensionDefinition<TConfig>;
// @alpha (undocumented)
export type SearchResultItemExtensionComponent = <
@@ -47,7 +49,8 @@ export type SearchResultItemExtensionOptions<
noTrack?: boolean;
},
> = {
id: string;
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
+3 -5
View File
@@ -44,8 +44,7 @@ describe('createSearchResultListItemExtension', () => {
const TechDocsSearchResultItemExtension =
createSearchResultListItemExtension({
id: 'techdocs',
attachTo: { id: 'plugin.search.page', input: 'items' },
namespace: 'techdocs',
configSchema: createSchemaFromZod(z =>
z
.object({
@@ -67,14 +66,13 @@ describe('createSearchResultListItemExtension', () => {
const ExploreSearchResultItemExtension =
createSearchResultListItemExtension({
id: 'explore',
attachTo: { id: 'plugin.search.page', input: 'items' },
namespace: 'explore',
predicate: result => result.type === 'explore',
component: async () => ExploreSearchResultItemComponent,
});
const SearchPageExtension = createPageExtension({
id: 'plugin.search.page',
namespace: 'search',
defaultPath: '/',
inputs: {
items: createExtensionInput({
+10 -6
View File
@@ -58,9 +58,13 @@ export type SearchResultItemExtensionOptions<
TConfig extends { noTrack?: boolean },
> = {
/**
* The extension id.
* The extension namespace.
*/
id: string;
namespace?: string;
/**
* The extension name.
*/
name?: string;
/**
* The extension attachment point (e.g., search modal or page).
*/
@@ -86,8 +90,6 @@ export type SearchResultItemExtensionOptions<
export function createSearchResultListItemExtension<
TConfig extends { noTrack?: boolean },
>(options: SearchResultItemExtensionOptions<TConfig>) {
const id = `plugin.search.result.item.${options.id}`;
const configSchema =
'configSchema' in options
? options.configSchema
@@ -98,9 +100,11 @@ export function createSearchResultListItemExtension<
) as PortableSchema<TConfig>);
return createExtension({
id,
kind: 'search-result-list-item',
namespace: options.namespace,
name: options.name,
attachTo: options.attachTo ?? {
id: 'plugin.search.page',
id: 'page:search',
input: 'items',
},
configSchema,
+4 -4
View File
@@ -4,7 +4,7 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
@@ -17,15 +17,15 @@ const _default: BackstagePlugin<
export default _default;
// @alpha (undocumented)
export const SearchApi: Extension<{}>;
export const SearchApi: ExtensionDefinition<{}>;
// @alpha (undocumented)
export const SearchNavItem: Extension<{
export const SearchNavItem: ExtensionDefinition<{
title: string;
}>;
// @alpha (undocumented)
export const SearchPage: Extension<{
export const SearchPage: ExtensionDefinition<{
path: string;
noTrack: boolean;
}>;
-2
View File
@@ -98,7 +98,6 @@ const useSearchPageStyles = makeStyles((theme: Theme) => ({
/** @alpha */
export const SearchPage = createPageExtension({
id: 'plugin.search.page',
routeRef: convertLegacyRouteRef(rootRouteRef),
configSchema: createSchemaFromZod(z =>
z.object({
@@ -235,7 +234,6 @@ export const SearchPage = createPageExtension({
/** @alpha */
export const SearchNavItem = createNavItemExtension({
id: 'plugin.search.nav.index',
routeRef: convertLegacyRouteRef(rootRouteRef),
title: 'Search',
icon: SearchIcon,
-1
View File
@@ -33,7 +33,6 @@ const StackOverflowApi = createApiExtension({
/** @alpha */
const StackOverflowSearchResultListItem = createSearchResultListItemExtension({
id: 'stack-overflow',
predicate: result => result.type === 'stack-overflow',
component: () =>
import('./search/StackOverflowSearchResultListItem').then(
+5 -2
View File
@@ -4,7 +4,7 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
@@ -17,7 +17,10 @@ const _default: BackstagePlugin<
export default _default;
// @alpha (undocumented)
export const TechRadarPage: Extension<{
export const sampleTechRadarApi: ExtensionDefinition<{}>;
// @alpha (undocumented)
export const TechRadarPage: ExtensionDefinition<{
height: number;
width: number;
title: string;
+1
View File
@@ -68,6 +68,7 @@
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.0.0",
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2023 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 { createExtensionTester } from '@backstage/frontend-test-utils';
import { screen } from '@testing-library/react';
import { TechRadarPage, sampleTechRadarApi } from './alpha';
describe('TechRadarPage', () => {
beforeAll(() => {
Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
value: () => ({ width: 100, height: 100 }),
configurable: true,
});
});
it('renders without exploding', async () => {
createExtensionTester(TechRadarPage).add(sampleTechRadarApi).render();
await expect(screen.findByText('Tech Radar')).resolves.toBeInTheDocument();
});
});
+3 -6
View File
@@ -29,7 +29,6 @@ import { rootRouteRef } from './plugin';
/** @alpha */
export const TechRadarPage = createPageExtension({
id: 'plugin.techradar.page',
defaultPath: '/tech-radar',
routeRef: convertLegacyRouteRef(rootRouteRef),
configSchema: createSchemaFromZod(z =>
@@ -48,11 +47,9 @@ export const TechRadarPage = createPageExtension({
import('./components').then(m => <m.RadarPage {...config} />),
});
const sampleTechRadarApi = createApiExtension({
api: techRadarApiRef,
factory() {
return createApiFactory(techRadarApiRef, new SampleTechRadarApi());
},
/** @alpha */
export const sampleTechRadarApi = createApiExtension({
factory: createApiFactory(techRadarApiRef, new SampleTechRadarApi()),
});
/** @alpha */
+2 -2
View File
@@ -4,7 +4,7 @@
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
@@ -23,7 +23,7 @@ const _default: BackstagePlugin<
export default _default;
// @alpha (undocumented)
export const TechDocsSearchResultListItemExtension: Extension<{
export const TechDocsSearchResultListItemExtension: ExtensionDefinition<{
lineClamp: number;
noTrack: boolean;
asListItem: boolean;
+31 -42
View File
@@ -46,53 +46,45 @@ import { createEntityContentExtension } from '@backstage/plugin-catalog-react/al
/** @alpha */
const techDocsStorage = createApiExtension({
api: techdocsStorageApiRef,
factory() {
return createApiFactory({
api: techdocsStorageApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi, fetchApi }) =>
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
}),
});
},
factory: createApiFactory({
api: techdocsStorageApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi, fetchApi }) =>
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
}),
}),
});
/** @alpha */
const techDocsClient = createApiExtension({
api: techdocsApiRef,
factory() {
return createApiFactory({
api: techdocsApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, fetchApi }) =>
new TechDocsClient({
configApi,
discoveryApi,
fetchApi,
}),
});
},
factory: createApiFactory({
api: techdocsApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, fetchApi }) =>
new TechDocsClient({
configApi,
discoveryApi,
fetchApi,
}),
}),
});
/** @alpha */
export const TechDocsSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'techdocs',
configSchema: createSchemaFromZod(z =>
z.object({
// TODO: Define how the icon can be configurable
@@ -118,7 +110,6 @@ export const TechDocsSearchResultListItemExtension =
* @alpha
*/
const TechDocsIndexPage = createPageExtension({
id: 'plugin.techdocs.indexPage',
defaultPath: '/docs',
routeRef: convertLegacyRouteRef(rootRouteRef),
loader: () =>
@@ -133,7 +124,7 @@ const TechDocsIndexPage = createPageExtension({
* @alpha
*/
const TechDocsReaderPage = createPageExtension({
id: 'plugin.techdocs.readerPage',
name: 'reader',
defaultPath: '/docs/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(rootDocsRouteRef),
loader: () =>
@@ -148,7 +139,6 @@ const TechDocsReaderPage = createPageExtension({
* @alpha
*/
const TechDocsEntityContent = createEntityContentExtension({
id: 'techdocs',
defaultPath: 'docs',
defaultTitle: 'TechDocs',
loader: () => import('./Router').then(m => <m.EmbeddedDocsRouter />),
@@ -156,7 +146,6 @@ const TechDocsEntityContent = createEntityContentExtension({
/** @alpha */
const TechDocsNavItem = createNavItemExtension({
id: 'plugin.techdocs.nav.index',
icon: LibraryBooks,
title: 'Docs',
routeRef: convertLegacyRouteRef(rootRouteRef),
-1
View File
@@ -27,7 +27,6 @@ import React from 'react';
export * from './translation';
const UserSettingsPage = createPageExtension({
id: 'plugin.user-settings.page',
defaultPath: '/settings',
routeRef: convertLegacyRouteRef(settingsRouteRef),
inputs: {
+1
View File
@@ -9587,6 +9587,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": ^4.12.2