diff --git a/.changeset/forty-insects-drop.md b/.changeset/forty-insects-drop.md new file mode 100644 index 0000000000..e2a94662ed --- /dev/null +++ b/.changeset/forty-insects-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Updates for compatibility with the new extension IDs. diff --git a/.changeset/rich-bees-breathe.md b/.changeset/rich-bees-breathe.md new file mode 100644 index 0000000000..7a40e3df2f --- /dev/null +++ b/.changeset/rich-bees-breathe.md @@ -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. diff --git a/.changeset/stale-frogs-agree.md b/.changeset/stale-frogs-agree.md new file mode 100644 index 0000000000..84052bded8 --- /dev/null +++ b/.changeset/stale-frogs-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Updates to match the introduction of `ExtensionDefinition` and new extension ID naming patterns. diff --git a/.changeset/stupid-wasps-fold.md b/.changeset/stupid-wasps-fold.md new file mode 100644 index 0000000000..260dd9f5f1 --- /dev/null +++ b/.changeset/stupid-wasps-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +Updates for compatibility with the new extension IDs. diff --git a/.changeset/ten-snakes-wait.md b/.changeset/ten-snakes-wait.md new file mode 100644 index 0000000000..1c15cb8d33 --- /dev/null +++ b/.changeset/ten-snakes-wait.md @@ -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. diff --git a/packages/app-next-example-plugin/src/plugin.tsx b/packages/app-next-example-plugin/src/plugin.tsx index a3c95e900c..99c377b7e5 100644 --- a/packages/app-next-example-plugin/src/plugin.tsx +++ b/packages/app-next-example-plugin/src/plugin.tsx @@ -21,7 +21,6 @@ import { } from '@backstage/frontend-plugin-api'; export const ExamplePage = createPageExtension({ - id: 'example.page', defaultPath: '/example', loader: () => import('./Component').then(m => ), }); diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 34cd78d480..3eaab2fa1a 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -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({ diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 791d2b38fa..ccfeb61b58 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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) => , }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index 1e9409a5e4..c5069f99c3 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -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 () => { diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md index 866517b5c8..93a2fa8cf1 100644 --- a/packages/core-compat-api/api-report.md +++ b/packages/core-compat-api/api-report.md @@ -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, -): Extension[]; +): ExtensionDefinition[]; // @public (undocumented) export function collectLegacyRoutes( diff --git a/packages/core-compat-api/src/collectLegacyComponents.test.tsx b/packages/core-compat-api/src/collectLegacyComponents.test.tsx index 0600f58cf8..7ca11a777e 100644 --- a/packages/core-compat-api/src/collectLegacyComponents.test.tsx +++ b/packages/core-compat-api/src/collectLegacyComponents.test.tsx @@ -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, }, diff --git a/packages/core-compat-api/src/collectLegacyComponents.tsx b/packages/core-compat-api/src/collectLegacyComponents.tsx index 6092ca1be7..6ae6df7cff 100644 --- a/packages/core-compat-api/src/collectLegacyComponents.tsx +++ b/packages/core-compat-api/src/collectLegacyComponents.tsx @@ -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> = { /** @public */ export function collectLegacyComponents(components: Partial) { - return Object.entries(components).reduce[]>( + return Object.entries(components).reduce[]>( (extensions, [name, component]) => { const ref = refs[name]; return ref diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index d09911b39d..3dd9f330cc 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -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, }, diff --git a/packages/core-compat-api/src/collectLegacyRoutes.tsx b/packages/core-compat-api/src/collectLegacyRoutes.tsx index 037107bfda..92bced29a2 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.tsx @@ -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[] + ExtensionDefinition[] >(); React.Children.forEach( @@ -95,19 +95,18 @@ export function collectLegacyRoutes( 'core.mountPoint', ); - const pluginId = plugin.getId(); - const detectedExtensions = - createdPluginIds.get(plugin) ?? new Array>(); + createdPluginIds.get(plugin) ?? + new Array>(); 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 }), ), ], }), diff --git a/packages/core-compat-api/src/convertLegacyApp.test.tsx b/packages/core-compat-api/src/convertLegacyApp.test.tsx index 0f1f6145c9..4a5be57bc8 100644 --- a/packages/core-compat-api/src/convertLegacyApp.test.tsx +++ b/packages/core-compat-api/src/convertLegacyApp.test.tsx @@ -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, }, ], diff --git a/packages/core-compat-api/src/convertLegacyApp.ts b/packages/core-compat-api/src/convertLegacyApp.ts index 93521b3955..3b12f0e1fd 100644 --- a/packages/core-compat-api/src/convertLegacyApp.ts +++ b/packages/core-compat-api/src/convertLegacyApp.ts @@ -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, diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx index 441cb5bcf0..472ed4905a 100644 --- a/packages/frontend-app-api/src/extensions/Core.tsx +++ b/packages/frontend-app-api/src/extensions/Core.tsx @@ -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({ diff --git a/packages/frontend-app-api/src/extensions/CoreLayout.tsx b/packages/frontend-app-api/src/extensions/CoreLayout.tsx index 0099b67751..94681120db 100644 --- a/packages/frontend-app-api/src/extensions/CoreLayout.tsx +++ b/packages/frontend-app-api/src/extensions/CoreLayout.tsx @@ -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( { diff --git a/packages/frontend-app-api/src/extensions/CoreNav.tsx b/packages/frontend-app-api/src/extensions/CoreNav.tsx index 8d0d4ea5de..21d113d3d6 100644 --- a/packages/frontend-app-api/src/extensions/CoreNav.tsx +++ b/packages/frontend-app-api/src/extensions/CoreNav.tsx @@ -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, diff --git a/packages/frontend-app-api/src/extensions/CoreRouter.tsx b/packages/frontend-app-api/src/extensions/CoreRouter.tsx index 510af04b51..088fd25ae7 100644 --- a/packages/frontend-app-api/src/extensions/CoreRouter.tsx +++ b/packages/frontend-app-api/src/extensions/CoreRouter.tsx @@ -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( diff --git a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx index 9acd27b6bf..d77839f7b3 100644 --- a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx +++ b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx @@ -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, diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index a06d236b77..f0ce6ac590 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -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[] = [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[]) { +function routeInfoFromExtensions(extensions: ExtensionDefinition[]) { 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, diff --git a/packages/frontend-app-api/src/tree/createAppTree.test.ts b/packages/frontend-app-api/src/tree/createAppTree.test.ts index 667e3a6fc3..15c8ab4750 100644 --- a/packages/frontend-app-api/src/tree/createAppTree.test.ts +++ b/packages/frontend-app-api/src/tree/createAppTree.test.ts @@ -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({}), diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index 802c71f729..7167038184 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -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('test'); const otherDataRef = createExtensionDataRef('other'); const inputMirrorDataRef = createExtensionDataRef('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( extension: Extension, @@ -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'", ); }); }); diff --git a/packages/frontend-app-api/src/tree/readAppExtensionsConfig.test.ts b/packages/frontend-app-api/src/tree/readAppExtensionsConfig.test.ts index 91f6992f29..504d5a6197 100644 --- a/packages/frontend-app-api/src/tree/readAppExtensionsConfig.test.ts +++ b/packages/frontend-app-api/src/tree/readAppExtensionsConfig.test.ts @@ -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'"`, ); }); }); diff --git a/packages/frontend-app-api/src/tree/readAppExtensionsConfig.ts b/packages/frontend-app-api/src/tree/readAppExtensionsConfig.ts index 2b5b51ce92..cca7064add 100644 --- a/packages/frontend-app-api/src/tree/readAppExtensionsConfig.ts +++ b/packages/frontend-app-api/src/tree/readAppExtensionsConfig.ts @@ -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: diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 39eceaaf28..2e9f73d87b 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -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; } +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; +} + 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: [], diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts index d84064b26a..4fca671554 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.test.ts @@ -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, diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 232de74de4..2713a9743e 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -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 () =>
First Page
, }), @@ -78,7 +80,6 @@ describe('createApp', () => { id: duplicatedFeatureId, extensions: [ createPageExtension({ - id: 'test.page.last', defaultPath: '/', loader: async () =>
Last Page
, }), @@ -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(` " root [ - + children [ - + content [ - + routes [ - + ] - + ] nav [ - + ] - + ] - + ] components [ - - - - + + + + ] themes [ - - + + ] " `); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 4e463fb526..f58dfb3159 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -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) { diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index e1ed76d82c..c491579eaf 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -392,7 +392,7 @@ export function createApiExtension< configSchema?: PortableSchema; inputs?: TInputs; }, -): Extension; +): ExtensionDefinition; export { createApiFactory }; @@ -405,6 +405,7 @@ export function createComponentExtension< TInputs extends AnyExtensionInputMap, >(options: { ref: TRef; + name?: string; disabled?: boolean; inputs?: TInputs; configSchema?: PortableSchema; @@ -421,7 +422,7 @@ export function createComponentExtension< inputs: Expand>; }) => TRef['T']; }; -}): Extension; +}): ExtensionDefinition; // @public (undocumented) export function createExtension< @@ -430,7 +431,7 @@ export function createExtension< TConfig = never, >( options: CreateExtensionOptions, -): Extension; +): ExtensionDefinition; // @public (undocumented) export function createExtensionDataRef( @@ -477,10 +478,14 @@ export interface CreateExtensionOptions< inputs: Expand>; }): Expand>; // (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; title: string; icon: IconComponent_2; -}): Extension<{ +}): ExtensionDefinition<{ title: string; }>; @@ -539,7 +545,8 @@ export function createPageExtension< configSchema: PortableSchema; } ) & { - id: string; + namespace?: string; + name?: string; attachTo?: { id: string; input: string; @@ -552,7 +559,7 @@ export function createPageExtension< inputs: Expand>; }) => Promise; }, -): Extension; +): ExtensionDefinition; // @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>; }) => Promise>; -}): Extension; +}): ExtensionDefinition; // @public export function createSubRouteRef< @@ -616,7 +624,9 @@ export function createSubRouteRef< }): MakeSubRouteRef, ParentParams>; // @public (undocumented) -export function createThemeExtension(theme: AppTheme): Extension; +export function createThemeExtension( + theme: AppTheme, +): ExtensionDefinition; export { DiscoveryApi }; @@ -703,6 +713,40 @@ export type ExtensionDataValues = { : never]?: TExtensionData[DataName]['T']; }; +// @public (undocumented) +export interface ExtensionDefinition { + // (undocumented) + $$type: '@backstage/ExtensionDefinition'; + // (undocumented) + attachTo: { + id: string; + input: string; + }; + // (undocumented) + configSchema?: PortableSchema; + // (undocumented) + disabled: boolean; + // (undocumented) + factory(options: { + node: AppNode; + config: TConfig; + inputs: Record< + string, + undefined | Record | Array> + >; + }): ExtensionDataValues; + // (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[]; + extensions: ExtensionDefinition[]; // (undocumented) featureFlags?: FeatureFlagConfig[]; } @@ -841,7 +885,7 @@ export interface PluginOptions< ExternalRoutes extends AnyExternalRoutes, > { // (undocumented) - extensions?: Extension[]; + extensions?: ExtensionDefinition[]; // (undocumented) externalRoutes?: ExternalRoutes; // (undocumented) diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index 49a9f95ed2..669335d577 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -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', }, - }), - ); + }); + }); }); }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index c9fa75c7fa..623c875837 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -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, }; diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts index e90c0b6b85..2585b0d694 100644 --- a/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts +++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts index 7b6dded022..0f4f721530 100644 --- a/packages/frontend-plugin-api/src/extensions/createApiExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.ts @@ -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:` + namespace: apiRef.id, attachTo: { id: 'core', input: 'apis' }, inputs: extensionInputs, configSchema, diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx index a20faa0b3a..c9780574b6 100644 --- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx @@ -32,6 +32,7 @@ export function createComponentExtension< TInputs extends AnyExtensionInputMap, >(options: { ref: TRef; + name?: string; disabled?: boolean; inputs?: TInputs; configSchema?: PortableSchema; @@ -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, diff --git a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx index 84fbe5ffee..207778fbd7 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavItemExtension.tsx @@ -24,15 +24,18 @@ import { RouteRef } from '../routing'; * @public */ export function createNavItemExtension(options: { - id: string; + namespace?: string; + name?: string; routeRef: RouteRef; 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), diff --git a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx index 93efadb0e1..ca95fad7bc 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx @@ -25,14 +25,15 @@ describe('createNavLogoExtension', () => { it('creates the extension properly', () => { expect( createNavLogoExtension({ - id: 'test', + name: 'test', logoFull:
Logo Full
, logoIcon:
Logo Icon
, }), ).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: { diff --git a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx index 5cfff81a9d..878764bad7 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.tsx @@ -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, }, diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx index f75821944a..525f10ee61 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx @@ -36,14 +36,15 @@ describe('createPageExtension', () => { expect( createPageExtension({ - id: 'test', + name: 'test', configSchema, loader: async () =>
, }), ).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 () =>
, }), ).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 () =>
, }), ).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 () =>
Component
, }), diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index 99733ba367..6796fe884d 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -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; } ) & { - id: string; + namespace?: string; + name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; inputs?: TInputs; @@ -54,9 +55,7 @@ export function createPageExtension< inputs: Expand>; }) => Promise; }, -): Extension { - const { id } = options; - +): ExtensionDefinition { const configSchema = 'configSchema' in options ? options.configSchema @@ -65,8 +64,10 @@ export function createPageExtension< ) as PortableSchema); 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, diff --git a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx index 964941e213..d8835ae788 100644 --- a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx @@ -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 () => () =>
, }); createExtensionTester( createExtension({ - id: 'dummy', + name: 'dummy', attachTo: { id: 'ignored', input: 'ignored' }, output: { element: coreExtensionData.reactElement, diff --git a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx index f869ed8f13..8709403407 100644 --- a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx @@ -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; disabled?: boolean; @@ -48,12 +49,12 @@ export function createSignInPageExtension< config: TConfig; inputs: Expand>; }) => Promise>; -}): Extension { - const { id } = options; - +}): ExtensionDefinition { 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, diff --git a/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts b/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts index d41121d51c..5554a08874 100644 --- a/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts +++ b/packages/frontend-plugin-api/src/extensions/createThemeExtension.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 071272d58f..07d52f5bb4 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -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'); }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 9443caeb66..e4c79a925b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -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>; } +/** @public */ +export interface ExtensionDefinition { + $$type: '@backstage/ExtensionDefinition'; + kind?: string; + namespace?: string; + name?: string; + attachTo: { id: string; input: string }; + disabled: boolean; + inputs: AnyExtensionInputMap; + output: AnyExtensionDataMap; + configSchema?: PortableSchema; + factory(options: { + node: AppNode; + config: TConfig; + inputs: Record< + string, + undefined | Record | Array> + >; + }): ExtensionDataValues; +} + /** @public */ export interface Extension { $$type: '@backstage/Extension'; @@ -112,12 +135,17 @@ export function createExtension< TConfig = never, >( options: CreateExtensionOptions, -): Extension { +): ExtensionDefinition { 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({ diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts index 90f878ceaa..772a214490 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.test.ts @@ -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: () => ({}), diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts index 3902654db7..6c368a0e8b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionOverrides.ts @@ -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[]; + extensions: ExtensionDefinition[]; 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; } diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts index f69b54aa42..0653c42d7c 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts @@ -28,48 +28,34 @@ import { createExtensionInput } from './createExtensionInput'; const nameExtensionDataRef = createExtensionDataRef('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"); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index 5e2a55ab88..ecc4d08f9a 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -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[]; + extensions?: ExtensionDefinition[]; featureFlags?: FeatureFlagConfig[]; } @@ -64,14 +65,33 @@ export function createPlugin< >( options: PluginOptions, ): BackstagePlugin { + 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; } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 191307ce1a..24f83be6e5 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -22,6 +22,7 @@ export { export { createExtension, type Extension, + type ExtensionDefinition, type CreateExtensionOptions, type ExtensionDataValues, type ExtensionInputValues, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts new file mode 100644 index 0000000000..8f3755ac5d --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -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, + ); + expect(resolved.id).toBe(expected); + }); + + it('should fail to resolve extension ID without namespace', () => { + expect(() => + resolveExtensionDefinition({ + kind: 'k', + } as ExtensionDefinition), + ).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), + ).toThrow( + 'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined', + ); + }); +}); diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts new file mode 100644 index 0000000000..42f3a75246 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -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( + definition: ExtensionDefinition, + context?: { namespace?: string }, +): Extension { + 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', + }; +} diff --git a/packages/frontend-test-utils/api-report.md b/packages/frontend-test-utils/api-report.md index 59f5696ddc..14e1c68c4c 100644 --- a/packages/frontend-test-utils/api-report.md +++ b/packages/frontend-test-utils/api-report.md @@ -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( - subject: Extension, + subject: ExtensionDefinition, options?: { config?: TConfig; }, @@ -36,7 +36,7 @@ export { ErrorWithContext }; export class ExtensionTester { // (undocumented) add( - extension: Extension, + extension: ExtensionDefinition, options?: { config?: TConfig; }, diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index 398acf4c74..e738bee7d1 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -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:
test
}), @@ -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'", ); }); }); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts index c95ece342d..257a47439c 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.ts +++ b/packages/frontend-test-utils/src/app/createExtensionTester.ts @@ -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( - subject: Extension, + subject: ExtensionDefinition, options?: { config?: TConfig }, ): ExtensionTester { const tester = new ExtensionTester(); @@ -33,16 +38,22 @@ export class ExtensionTester { } readonly #extensions = new Array<{ - extension: Extension; + id: string; + extension: ExtensionDefinition; config?: JsonValue; }>(); add( - extension: Extension, + extension: ExtensionDefinition, 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( - subject: Extension, + subject: ExtensionDefinition, options?: { config?: TConfig }, ): ExtensionTester { return ExtensionTester.forSubject(subject, options); diff --git a/plugins/adr/api-report-alpha.md b/plugins/adr/api-report-alpha.md index 3864c85697..4c9ac1d17e 100644 --- a/plugins/adr/api-report-alpha.md +++ b/plugins/adr/api-report-alpha.md @@ -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; }>; diff --git a/plugins/adr/src/alpha.tsx b/plugins/adr/src/alpha.tsx index 1f6fa0711d..ea4d32e0da 100644 --- a/plugins/adr/src/alpha.tsx +++ b/plugins/adr/src/alpha.tsx @@ -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 diff --git a/plugins/catalog-import/src/alpha.tsx b/plugins/catalog-import/src/alpha.tsx index 18ddda2ceb..7ecbac5346 100644 --- a/plugins/catalog-import/src/alpha.tsx +++ b/plugins/catalog-import/src/alpha.tsx @@ -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 => ), diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index 913055f8b0..1be108f695 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -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>; }) => Promise; -}): 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>; }) => Promise; -}): Extension<{ +}): ExtensionDefinition<{ title: string; path: string; filter?: string | undefined; diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx index 43b7156b34..ccb0efbe6d 100644 --- a/plugins/catalog-react/src/alpha.tsx +++ b/plugins/catalog-react/src/alpha.tsx @@ -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>; }) => Promise; }) { - 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>; }) => Promise; }) { - 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, diff --git a/plugins/catalog/api-report-alpha.md b/plugins/catalog/api-report-alpha.md index 52afb0894a..3226d10270 100644 --- a/plugins/catalog/api-report-alpha.md +++ b/plugins/catalog/api-report-alpha.md @@ -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; loader: (options: { config: TConfig }) => Promise; -}): Extension; +}): ExtensionDefinition; // @alpha (undocumented) const _default: BackstagePlugin< diff --git a/plugins/catalog/src/alpha/createCatalogFilterExtension.tsx b/plugins/catalog/src/alpha/createCatalogFilterExtension.tsx index 7dae30c1fa..48ee647b81 100644 --- a/plugins/catalog/src/alpha/createCatalogFilterExtension.tsx +++ b/plugins/catalog/src/alpha/createCatalogFilterExtension.tsx @@ -28,16 +28,17 @@ export function createCatalogFilterExtension< TInputs extends AnyExtensionInputMap, TConfig = never, >(options: { - id: string; + namespace?: string; + name?: string; inputs?: TInputs; configSchema?: PortableSchema; loader: (options: { config: TConfig }) => Promise; }) { - 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: { diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx index 572e2caa3c..b6a804a576 100644 --- a/plugins/catalog/src/alpha/entityCards.tsx +++ b/plugins/catalog/src/alpha/entityCards.tsx @@ -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 => ( @@ -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 => ( @@ -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 => ( @@ -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 => ( @@ -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 => ( @@ -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 => ( @@ -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 => ( diff --git a/plugins/catalog/src/alpha/entityContents.tsx b/plugins/catalog/src/alpha/entityContents.tsx index e280326b02..72adf4ec60 100644 --- a/plugins/catalog/src/alpha/entityContents.tsx +++ b/plugins/catalog/src/alpha/entityContents.tsx @@ -26,7 +26,7 @@ import { } from '@backstage/plugin-catalog-react/alpha'; export const OverviewEntityContent = createEntityContentExtension({ - id: 'overview', + name: 'overview', defaultPath: '/', defaultTitle: 'Overview', disabled: false, diff --git a/plugins/catalog/src/alpha/filters.tsx b/plugins/catalog/src/alpha/filters.tsx index 7b73c2d14b..be06e70611 100644 --- a/plugins/catalog/src/alpha/filters.tsx +++ b/plugins/catalog/src/alpha/filters.tsx @@ -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 ; @@ -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'), diff --git a/plugins/catalog/src/alpha/navItems.tsx b/plugins/catalog/src/alpha/navItems.tsx index 77ea394a97..8dde27b95a 100644 --- a/plugins/catalog/src/alpha/navItems.tsx +++ b/plugins/catalog/src/alpha/navItems.tsx @@ -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, diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index 6324f53083..7dd32f7748 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -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: { diff --git a/plugins/catalog/src/alpha/searchResultItems.tsx b/plugins/catalog/src/alpha/searchResultItems.tsx index f677869136..c84aabf300 100644 --- a/plugins/catalog/src/alpha/searchResultItems.tsx +++ b/plugins/catalog/src/alpha/searchResultItems.tsx @@ -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( diff --git a/plugins/explore/api-report-alpha.md b/plugins/explore/api-report-alpha.md index 8670338f3e..4d1afa89bf 100644 --- a/plugins/explore/api-report-alpha.md +++ b/plugins/explore/api-report-alpha.md @@ -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; }>; diff --git a/plugins/explore/src/alpha.tsx b/plugins/explore/src/alpha.tsx index 1825af5ddb..f06379c84b 100644 --- a/plugins/explore/src/alpha.tsx +++ b/plugins/explore/src/alpha.tsx @@ -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( diff --git a/plugins/graphiql/api-report-alpha.md b/plugins/graphiql/api-report-alpha.md index 3362cf0a59..c5895067df 100644 --- a/plugins/graphiql/api-report-alpha.md +++ b/plugins/graphiql/api-report-alpha.md @@ -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(options: { - id: string; +export function createGraphiQLEndpointExtension(options: { + namespace?: string; + name?: string; configSchema?: PortableSchema; disabled?: boolean; factory: (options: { config: TConfig }) => { endpoint: GraphQLEndpoint; }; -}): Extension; +}): ExtensionDefinition; // @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; }>; diff --git a/plugins/graphiql/src/alpha.tsx b/plugins/graphiql/src/alpha.tsx index 760bc77d8e..d9470b7d46 100644 --- a/plugins/graphiql/src/alpha.tsx +++ b/plugins/graphiql/src/alpha.tsx @@ -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 => ), @@ -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( /** @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(options: { - id: string; +export function createGraphiQLEndpointExtension(options: { + namespace?: string; + name?: string; configSchema?: PortableSchema; 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(options: { } /** @alpha */ -const gitlabGraphiQLBrowseExtension = createEndpointExtension({ - id: 'gitlab', +const gitlabGraphiQLBrowseExtension = createGraphiQLEndpointExtension({ + name: 'gitlab', disabled: true, configSchema: createSchemaFromZod(z => z diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index c8308f7c0a..9ccb4c8ed6 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -33,7 +33,6 @@ const rootRouteRef = createRouteRef(); export const titleExtensionDataRef = createExtensionDataRef('title'); const HomepageCompositionRootExtension = createPageExtension({ - id: 'home', defaultPath: '/home', routeRef: rootRouteRef, inputs: { diff --git a/plugins/search-react/api-report-alpha.md b/plugins/search-react/api-report-alpha.md index 9c1248b536..285f1232c2 100644 --- a/plugins/search-react/api-report-alpha.md +++ b/plugins/search-react/api-report-alpha.md @@ -6,7 +6,7 @@ /// 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): Extension; +>( + options: SearchResultItemExtensionOptions, +): ExtensionDefinition; // @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; diff --git a/plugins/search-react/src/alpha.test.tsx b/plugins/search-react/src/alpha.test.tsx index e1a8166390..6950dec664 100644 --- a/plugins/search-react/src/alpha.test.tsx +++ b/plugins/search-react/src/alpha.test.tsx @@ -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({ diff --git a/plugins/search-react/src/alpha.tsx b/plugins/search-react/src/alpha.tsx index bb738a2b08..c733e2ee9f 100644 --- a/plugins/search-react/src/alpha.tsx +++ b/plugins/search-react/src/alpha.tsx @@ -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) { - const id = `plugin.search.result.item.${options.id}`; - const configSchema = 'configSchema' in options ? options.configSchema @@ -98,9 +100,11 @@ export function createSearchResultListItemExtension< ) as PortableSchema); 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, diff --git a/plugins/search/api-report-alpha.md b/plugins/search/api-report-alpha.md index 99f3d07b57..533734f8e9 100644 --- a/plugins/search/api-report-alpha.md +++ b/plugins/search/api-report-alpha.md @@ -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; }>; diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index ef11828c71..3f1d307237 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -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, diff --git a/plugins/stack-overflow/src/alpha.tsx b/plugins/stack-overflow/src/alpha.tsx index 3708271ef1..f34c91f78b 100644 --- a/plugins/stack-overflow/src/alpha.tsx +++ b/plugins/stack-overflow/src/alpha.tsx @@ -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( diff --git a/plugins/tech-radar/api-report-alpha.md b/plugins/tech-radar/api-report-alpha.md index da8f12cf0e..bcc9142223 100644 --- a/plugins/tech-radar/api-report-alpha.md +++ b/plugins/tech-radar/api-report-alpha.md @@ -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; diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index bcfcc7c583..2fba4121c7 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -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", diff --git a/plugins/tech-radar/src/alpha.test.tsx b/plugins/tech-radar/src/alpha.test.tsx new file mode 100644 index 0000000000..37d39c412b --- /dev/null +++ b/plugins/tech-radar/src/alpha.test.tsx @@ -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(); + }); +}); diff --git a/plugins/tech-radar/src/alpha.tsx b/plugins/tech-radar/src/alpha.tsx index f9a34e2158..2d15e92537 100644 --- a/plugins/tech-radar/src/alpha.tsx +++ b/plugins/tech-radar/src/alpha.tsx @@ -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 => ), }); -const sampleTechRadarApi = createApiExtension({ - api: techRadarApiRef, - factory() { - return createApiFactory(techRadarApiRef, new SampleTechRadarApi()); - }, +/** @alpha */ +export const sampleTechRadarApi = createApiExtension({ + factory: createApiFactory(techRadarApiRef, new SampleTechRadarApi()), }); /** @alpha */ diff --git a/plugins/techdocs/api-report-alpha.md b/plugins/techdocs/api-report-alpha.md index 2b63d6e691..afc3202a43 100644 --- a/plugins/techdocs/api-report-alpha.md +++ b/plugins/techdocs/api-report-alpha.md @@ -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; diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index 533b4cb524..82bda828e2 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -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 => ), @@ -156,7 +146,6 @@ const TechDocsEntityContent = createEntityContentExtension({ /** @alpha */ const TechDocsNavItem = createNavItemExtension({ - id: 'plugin.techdocs.nav.index', icon: LibraryBooks, title: 'Docs', routeRef: convertLegacyRouteRef(rootRouteRef), diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index 419ee2808d..dfcd1d7e2e 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -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: { diff --git a/yarn.lock b/yarn.lock index 45e22286fa..aac0b29773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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