diff --git a/.changeset/fresh-apes-dress.md b/.changeset/fresh-apes-dress.md new file mode 100644 index 0000000000..6e20e53bcd --- /dev/null +++ b/.changeset/fresh-apes-dress.md @@ -0,0 +1,8 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-test-utils': patch +'@backstage/frontend-app-api': patch +'@backstage/plugin-app': minor +--- + +Introduce the `@backstage/plugin-app` package to hold all of the built-in extensions for easy consumption and overriding. diff --git a/.changeset/fresh-pumas-clean.md b/.changeset/fresh-pumas-clean.md new file mode 100644 index 0000000000..2140f62651 --- /dev/null +++ b/.changeset/fresh-pumas-clean.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-app-backend': patch +'@backstage/plugin-app-node': patch +--- + +Fixing dependency metadata with the new `@backstage/plugin-app` package diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index ceb3f34c35..ddec37a4ea 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -495,7 +495,7 @@ The entity pages are typically defined in `packages/app/src/components/catalog` New apps feature a built-in sidebar extension (`app/nav`) that will render all nav item extensions provided by plugins. This is a placeholder implementation and not intended as a long-term solution. In the future we will aim to provide a more flexible sidebar extension that allows for more customization out of the box. -Because the built-in sidebar is quite limited you may want to override the sidebar with your own custom implementation. To do so, use `createExtension` directly and refer to the [original sidebar implementation](https://github.com/backstage/backstage/blob/master/packages/frontend-app-api/src/extensions/AppNav.tsx). The following is an example of how to take your existing sidebar from the `Root` component that you typically find in `packages/app/src/components/Root.tsx`, and use it in an [extension override](../architecture/25-extension-overrides.md): +Because the built-in sidebar is quite limited you may want to override the sidebar with your own custom implementation. To do so, use `createExtension` directly and refer to the [original sidebar implementation](https://github.com/backstage/backstage/blob/master/plugins/app/src/extensions/AppNav.tsx). The following is an example of how to take your existing sidebar from the `Root` component that you typically find in `packages/app/src/components/Root.tsx`, and use it in an [extension override](../architecture/25-extension-overrides.md): ```tsx const nav = createExtension({ diff --git a/packages/app-next/package.json b/packages/app-next/package.json index fa37cce5c8..1aeaccbf97 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -24,6 +24,7 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-api-docs": "workspace:^", + "@backstage/plugin-app": "workspace:^", "@backstage/plugin-app-visualizer": "workspace:^", "@backstage/plugin-auth-react": "workspace:^", "@backstage/plugin-catalog": "workspace:^", diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index f3e2baee38..b7b9f6781e 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -262,7 +262,15 @@ describe('collectLegacyRoutes', () => { component: () => Promise.resolve(() => { const app = useApp(); - return
plugins: {app.getPlugins().map(p => p.getId())}
; + return ( +
+ plugins:{' '} + {app + .getPlugins() + .map(p => p.getId()) + .join(', ')} +
+ ); }), }), ); @@ -276,7 +284,7 @@ describe('collectLegacyRoutes', () => { render(createSpecializedApp({ features }).createRoot()); await expect( - screen.findByText('plugins: test'), + screen.findByText('plugins: app, test'), ).resolves.toBeInTheDocument(); }); diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index 595c49cf73..bee379a792 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -52,7 +52,7 @@ describe('BackwardsCompatProvider', () => { const app = useApp(); return (
- plugins: + plugins:{' '} {app .getPlugins() .map(p => p.getId()) @@ -74,7 +74,7 @@ describe('BackwardsCompatProvider', () => { ); expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(` - "plugins: + "plugins: app components: NotFoundErrorPage, BootErrorPage, Progress, Router, ErrorBoundaryFallback icons: brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, user, warning" `); diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 3f13b4ad93..b901896b6a 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -38,6 +38,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-app": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", diff --git a/packages/frontend-app-api/src/extensions/Root.ts b/packages/frontend-app-api/src/extensions/Root.ts index a613a8dcfa..3b1c237e7a 100644 --- a/packages/frontend-app-api/src/extensions/Root.ts +++ b/packages/frontend-app-api/src/extensions/Root.ts @@ -32,6 +32,6 @@ export const Root = createExtension({ replaces: [{ id: 'app', input: 'apis' }], }), }, - output: [], - factory: () => [], + output: [coreExtensionData.reactElement], + factory: ({ inputs }) => inputs.app, }); diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts index 4d450f0ae4..4e1c37e9a0 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts @@ -29,12 +29,15 @@ import { createRouteRef, } from '@backstage/frontend-plugin-api'; import { MockConfigApi, TestApiRegistry } from '@backstage/test-utils'; +import appPlugin from '@backstage/plugin-app'; -import { builtinExtensions } from '../wiring/createApp'; import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig'; import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs'; import { resolveAppTree } from '../tree/resolveAppTree'; import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; +import { Root } from '../extensions/Root'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; const ref1 = createRouteRef(); const ref2 = createRouteRef(); @@ -83,12 +86,12 @@ function routeInfoFromExtensions(extensions: ExtensionDefinition[]) { }); const tree = resolveAppTree( - 'app', + 'root', resolveAppNodeSpecs({ - features: [plugin], - builtinExtensions, + features: [appPlugin, plugin], + builtinExtensions: [resolveExtensionDefinition(Root)], parameters: readAppExtensionsConfig(new MockConfigApi({})), - forbidden: new Set(['app']), + forbidden: new Set(['root']), }), ); diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index e29a3afe94..908298cb9a 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -29,6 +29,7 @@ import { CreateAppFeatureLoader, createApp } from './createApp'; import { MockConfigApi, renderWithEffects } from '@backstage/test-utils'; import React from 'react'; import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; +import appPlugin from '@backstage/plugin-app'; describe('createApp', () => { it('should allow themes to be installed', async () => { @@ -136,7 +137,7 @@ describe('createApp', () => { configLoader: async () => ({ config: new MockConfigApi({ key: 'config-value' }), }), - features: [loader], + features: [appPlugin, loader], }); await renderWithEffects(app.createRoot()); @@ -174,6 +175,7 @@ describe('createApp', () => { const app = createApp({ configLoader: async () => ({ config: new MockConfigApi({}) }), features: [ + appPlugin, createFrontendPlugin({ id: 'test', featureFlags: [{ name: 'test-1' }], @@ -255,13 +257,53 @@ describe('createApp', () => { const { tree } = appTreeApi!.getTree(); expect(String(tree.root)).toMatchInlineSnapshot(` - " + " + apis [ + + + + + + + + + + + + + + + + + + + + + themes [ + + + ] + + + components [ + + + + ] + + + + + ] app [ root [ children [ + nav [ + + ] content [ routes [ @@ -269,9 +311,6 @@ describe('createApp', () => { ] ] - nav [ - - ] ] elements [ @@ -282,43 +321,6 @@ describe('createApp', () => { ] ] - apis [ - - themes [ - - - ] - - - - - - components [ - - - - ] - - - - - - - - - - - - - - - - - - - - - ] " `); }); @@ -354,4 +356,27 @@ describe('createApp', () => { expect(screen.queryByText('Custom loading message')).toBeNull(); }); + + it('should allow overriding the app plugin', async () => { + const app = createApp({ + configLoader: () => new Promise(() => {}), + features: [ + appPlugin.withOverrides({ + extensions: [ + appPlugin.getExtension('app/root').override({ + factory: () => [ + coreExtensionData.reactElement( +
Custom app root element
, + ), + ], + }), + ], + }), + ], + }); + + await renderWithEffects(app.createRoot()); + + expect(screen.queryByText('Custom app root element')).toBeNull(); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index 242709cdb2..37b7d2758a 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -22,7 +22,6 @@ import { AppTreeApi, appTreeApiRef, coreExtensionData, - ExtensionDefinition, FrontendFeature, RouteRef, ExternalRouteRef, @@ -34,10 +33,7 @@ import { createApiFactory, routeResolutionApiRef, } from '@backstage/frontend-plugin-api'; -import { App } from '../extensions/App'; -import { AppRoutes } from '../extensions/AppRoutes'; -import { AppLayout } from '../extensions/AppLayout'; -import { AppNav } from '../extensions/AppNav'; + import { AnyApiFactory, ApiHolder, @@ -45,62 +41,31 @@ import { configApiRef, featureFlagsApiRef, identityApiRef, - errorApiRef, - discoveryApiRef, - fetchApiRef, } from '@backstage/core-plugin-api'; import { getAvailableFeatures } from './discovery'; -import { - ApiFactoryRegistry, - ApiProvider, - ApiResolver, -} from '@backstage/core-app-api'; +import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api'; // TODO: Get rid of all of these -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { isProtectedApp } from '../../../core-app-api/src/app/isProtectedApp'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppThemeProvider } from '../../../core-app-api/src/app/AppThemeProvider'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; + // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultConfigLoader'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs'; // 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 } from '../../../app-defaults/src/defaults'; -import { - oauthRequestDialogAppRootElement, - alertDisplayAppRootElement, -} from '../extensions/elements'; import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode'; import { CreateAppRouteBinder } from '../routing'; import { RouteResolver } from '../routing/RouteResolver'; import { resolveRouteBindings } from '../routing/resolveRouteBindings'; import { collectRouteIds } from '../routing/collectRouteIds'; -import { - DefaultProgressComponent, - DefaultErrorBoundaryComponent, - DefaultNotFoundErrorPageComponent, -} from '../extensions/components'; -import { InternalAppContext } from './InternalAppContext'; -import { AppRoot } from '../extensions/AppRoot'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides'; import { stringifyError } from '@backstage/errors'; import { getBasePath } from '../routing/getBasePath'; -import { AppThemeApi, DarkTheme, LightTheme } from '../extensions/AppThemeApi'; -import { IconsApi } from '../extensions/IconsApi'; -import { TranslationsApi } from '../extensions/TranslationsApi'; -import { ComponentsApi } from '../extensions/ComponentsApi'; -import { AppLanguageApi } from '../extensions/AppLanguageApi'; -import { FeatureFlagsApi } from '../extensions/FeatureFlagsApi'; import { Root } from '../extensions/Root'; import { resolveAppTree } from '../tree/resolveAppTree'; import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs'; @@ -108,35 +73,10 @@ import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig'; import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry'; - -const DefaultApis = defaultApis.map(factory => - ApiBlueprint.make({ namespace: factory.api.id, params: { factory } }), -); - -export const builtinExtensions = ( - [ - Root, - App, - AppRoot, - AppRoutes, - AppNav, - AppLayout, - DefaultProgressComponent, - DefaultErrorBoundaryComponent, - DefaultNotFoundErrorPageComponent, - LightTheme, - DarkTheme, - oauthRequestDialogAppRootElement, - alertDisplayAppRootElement, - AppThemeApi, - AppLanguageApi, - IconsApi, - TranslationsApi, - ComponentsApi, - FeatureFlagsApi, - ...DefaultApis, - ] as ExtensionDefinition[] -).map(def => resolveExtensionDefinition(def)); +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +import { BackstageRouteObject } from '../routing/types'; +import appPlugin from '@backstage/plugin-app'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -271,6 +211,7 @@ class AppTreeApiProxy implements AppTreeApi { // Helps delay callers from reaching out to the API before the app tree has been materialized class RouteResolutionApiProxy implements RouteResolutionApi { #delegate: RouteResolutionApi | undefined; + #routeObjects: BackstageRouteObject[] | undefined; constructor( private readonly tree: AppTree, @@ -307,9 +248,14 @@ class RouteResolutionApiProxy implements RouteResolutionApi { this.routeBindings, this.basePath, ); + this.#routeObjects = routeInfo.routeObjects; return routeInfo; } + + getRouteObjects() { + return this.#routeObjects; + } } /** @@ -324,24 +270,23 @@ export function createSpecializedApp(options?: { bindRoutes?(context: { bind: CreateAppRouteBinder }): void; }): { createRoot(): JSX.Element } { const { - features: duplicatedFeatures = [], + features: featuresWithoutApp = [], config = new ConfigReader({}, 'empty-config'), } = options ?? {}; - const features = deduplicateFeatures(duplicatedFeatures); + const features = deduplicateFeatures([appPlugin, ...featuresWithoutApp]); const tree = resolveAppTree( 'root', resolveAppNodeSpecs({ features, - builtinExtensions, + builtinExtensions: [resolveExtensionDefinition(Root)], parameters: readAppExtensionsConfig(config), forbidden: new Set(['root']), }), ); const factories = createApiFactories({ tree }); - const appTreeApi = new AppTreeApiProxy(tree); const routeResolutionApi = new RouteResolutionApiProxy( tree, @@ -364,29 +309,12 @@ export function createSpecializedApp(options?: { ], }); - for (const appNode of tree.root.edges.attachments.get('app') ?? []) { - instantiateAppNodeTree(appNode, apiHolder); - } + // Now instantiate the entire tree, which will skip anything that's already been instantiated + instantiateAppNodeTree(tree.root, apiHolder); - const routeInfo = routeResolutionApi.initialize(); + routeResolutionApi.initialize(); appTreeApi.initialize(); - if (isProtectedApp()) { - const discoveryApi = apiHolder.get(discoveryApiRef); - const errorApi = apiHolder.get(errorApiRef); - const fetchApi = apiHolder.get(fetchApiRef); - if (!discoveryApi || !errorApi || !fetchApi) { - throw new Error( - 'App is running in protected mode but missing required APIs', - ); - } - appIdentityProxy.enableCookieAuth({ - discoveryApi, - errorApi, - fetchApi, - }); - } - const featureFlagApi = apiHolder.get(featureFlagsApiRef); if (featureFlagApi) { for (const feature of features) { @@ -406,21 +334,9 @@ export function createSpecializedApp(options?: { } } - const rootEl = tree.root.edges.attachments - .get('app')![0] - .instance!.getData(coreExtensionData.reactElement); + const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement); - const AppComponent = () => ( - - - - {rootEl} - - - - ); + const AppComponent = () => rootEl; return { createRoot() { diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx index 6e725324a1..cda0085fec 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -16,15 +16,13 @@ import React from 'react'; import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { coreExtensionData, createExtension, createExtensionInput, - createFrontendPlugin, } from '../wiring'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; -import { MockConfigApi } from '@backstage/test-utils'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; describe('AppRootWrapperBlueprint', () => { it('should return an extension with sensible defaults', () => { @@ -61,27 +59,20 @@ describe('AppRootWrapperBlueprint', () => { it('should render the simple component wrapper', async () => { const extension = AppRootWrapperBlueprint.make({ + name: 'test', params: { Component: () =>
Hello
, }, }); - const app = createSpecializedApp({ - features: [ - createFrontendPlugin({ - id: 'test', - extensions: [extension], - }), - ], - }); - - render(app.createRoot()); + renderInTestApp(
, { extensions: [extension] }); await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()); }); it('should render the complex component wrapper', async () => { const extension = AppRootWrapperBlueprint.makeWithOverrides({ + name: 'test', config: { schema: { name: z => z.string(), @@ -104,24 +95,17 @@ describe('AppRootWrapperBlueprint', () => { }, }); - const app = createSpecializedApp({ - features: [ - createFrontendPlugin({ - id: 'test', - extensions: [ - extension, - createExtension({ - name: 'test-child', - attachTo: { id: 'app-root-wrapper:test', input: 'children' }, - output: [coreExtensionData.reactElement], - factory: () => [ - coreExtensionData.reactElement(
Its Me
), - ], - }), - ], + renderInTestApp(
, { + extensions: [ + extension, + createExtension({ + name: 'test-child', + attachTo: { id: 'app-root-wrapper:test', input: 'children' }, + output: [coreExtensionData.reactElement], + factory: () => [coreExtensionData.reactElement(
Its Me
)], }), ], - config: new MockConfigApi({ + config: { app: { extensions: [ { @@ -129,11 +113,9 @@ describe('AppRootWrapperBlueprint', () => { }, ], }, - }), + }, }); - render(app.createRoot()); - await waitFor(() => { expect(screen.getByTestId('Robin-1')).toBeInTheDocument(); expect(screen.getByText('Its Me')).toBeInTheDocument(); diff --git a/packages/frontend-plugin-api/src/blueprints/IconBundleBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/IconBundleBlueprint.ts index de2df761ff..369ac052f5 100644 --- a/packages/frontend-plugin-api/src/blueprints/IconBundleBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/IconBundleBlueprint.ts @@ -25,7 +25,7 @@ const iconsDataRef = createExtensionDataRef<{ export const IconBundleBlueprint = createExtensionBlueprint({ kind: 'icon-bundle', namespace: 'app', - attachTo: { id: 'api:icons', input: 'icons' }, + attachTo: { id: 'api:app/icons', input: 'icons' }, output: [iconsDataRef], config: { schema: { diff --git a/packages/frontend-plugin-api/src/blueprints/RouterBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/RouterBlueprint.test.tsx index 405c673d60..3062d6d9de 100644 --- a/packages/frontend-plugin-api/src/blueprints/RouterBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/RouterBlueprint.test.tsx @@ -17,15 +17,12 @@ import React from 'react'; import { RouterBlueprint } from './RouterBlueprint'; import { MemoryRouter } from 'react-router-dom'; import { render, waitFor } from '@testing-library/react'; -import { createSpecializedApp } from '@backstage/frontend-app-api'; import { coreExtensionData, createExtension, createExtensionInput, - createExtensionOverrides, } from '../wiring'; -import { MockConfigApi } from '@backstage/test-utils'; -import { PageBlueprint } from './PageBlueprint'; +import { createExtensionTester } from '@backstage/frontend-test-utils'; describe('RouterBlueprint', () => { it('should return an extension when calling make with sensible defaults', () => { @@ -72,24 +69,14 @@ describe('RouterBlueprint', () => { }, }); - const app = createSpecializedApp({ - features: [ - createExtensionOverrides({ - extensions: [ - extension, - PageBlueprint.make({ - namespace: 'test', - params: { - defaultPath: '/', - loader: async () =>
, - }, - }), - ], - }), - ], - }); + const tester = createExtensionTester(extension); + const Component = tester.get(RouterBlueprint.dataRefs.component); - const { getByTestId } = render(app.createRoot()); + const { getByTestId } = render( + +
+ , + ); await waitFor(() => { expect(getByTestId('test-contents')).toBeInTheDocument(); @@ -124,44 +111,28 @@ describe('RouterBlueprint', () => { }, }); - const app = createSpecializedApp({ - features: [ - createExtensionOverrides({ - extensions: [ - extension, - createExtension({ - namespace: 'test', - attachTo: { - id: 'app-router-component:test/test', - input: 'children', - }, - output: [coreExtensionData.reactElement], - *factory() { - yield coreExtensionData.reactElement(
); - }, - }), - PageBlueprint.make({ - namespace: 'test', - params: { - defaultPath: '/', - loader: async () =>
, - }, - }), - ], - }), - ], - config: new MockConfigApi({ - app: { - extensions: [ - { - 'app-router-component:test/test': { config: { name: 'Robin' } }, - }, - ], + const tester = createExtensionTester(extension, { + config: { name: 'Robin' }, + }).add( + createExtension({ + namespace: 'test', + attachTo: { + id: 'app-router-component:test/test', + input: 'children', + }, + output: [coreExtensionData.reactElement], + *factory() { + yield coreExtensionData.reactElement(
); }, }), - }); + ); + const Component = tester.get(RouterBlueprint.dataRefs.component); - const { getByTestId } = render(app.createRoot()); + const { getByTestId } = render( + +
+ , + ); await waitFor(() => { expect(getByTestId('test-contents')).toBeInTheDocument(); diff --git a/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.test.ts index 71c5f9b918..6d3f6280a3 100644 --- a/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.test.ts @@ -33,7 +33,7 @@ describe('ThemeBlueprint', () => { "$$type": "@backstage/ExtensionDefinition", "T": undefined, "attachTo": { - "id": "api:app-theme", + "id": "api:app/app-theme", "input": "themes", }, "configSchema": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.ts index c06078b122..ca33970125 100644 --- a/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/ThemeBlueprint.ts @@ -29,7 +29,7 @@ const themeDataRef = createExtensionDataRef().with({ export const ThemeBlueprint = createExtensionBlueprint({ kind: 'theme', namespace: 'app', - attachTo: { id: 'api:app-theme', input: 'themes' }, + attachTo: { id: 'api:app/app-theme', input: 'themes' }, output: [themeDataRef], dataRefs: { theme: themeDataRef, diff --git a/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.test.ts index 7d045b5f6c..6bccd68c53 100644 --- a/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.test.ts @@ -48,7 +48,7 @@ describe('TranslationBlueprint', () => { "$$type": "@backstage/ExtensionDefinition", "T": undefined, "attachTo": { - "id": "api:translations", + "id": "api:app/translations", "input": "translations", }, "configSchema": undefined, diff --git a/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.ts index ea7aa64713..dd4a9ddc09 100644 --- a/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/TranslationBlueprint.ts @@ -28,7 +28,7 @@ const translationDataRef = createExtensionDataRef< */ export const TranslationBlueprint = createExtensionBlueprint({ kind: 'translation', - attachTo: { id: 'api:translations', input: 'translations' }, + attachTo: { id: 'api:app/translations', input: 'translations' }, output: [translationDataRef], dataRefs: { translation: translationDataRef, diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx index 35d003e99b..c8fa907bc8 100644 --- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx @@ -35,7 +35,7 @@ export function createComponentExtension(options: { kind: 'component', namespace: options.ref.id, name: options.name, - attachTo: { id: 'api:components', input: 'components' }, + attachTo: { id: 'api:app/components', input: 'components' }, disabled: options.disabled, output: [createComponentExtension.componentDataRef], factory() { diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 63256d9a3a..83ae085856 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -127,7 +127,7 @@ function createTestAppRoot({ config: JsonObject; }) { return createApp({ - features, + features: [...features], configLoader: async () => ({ config: new MockConfigApi(config) }), }).createRoot(); } diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 526e8b1713..ed53d7c1f8 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -34,6 +34,7 @@ "@backstage/config": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-app": "workspace:^", "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^" }, diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index cc919f3f3d..7923050050 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -27,12 +27,12 @@ import { coreExtensionData, RouteRef, useRouteRef, - createExtensionInput, IconComponent, RouterBlueprint, NavItemBlueprint, FrontendFeature, } from '@backstage/frontend-plugin-api'; +import appPlugin from '@backstage/plugin-app'; /** * Options to customize the behavior of the test app. @@ -92,38 +92,36 @@ const NavItem = (props: { ); }; -export const TestAppNavExtension = createExtension({ - namespace: 'app', - name: 'nav', - attachTo: { id: 'app/layout', input: 'nav' }, - inputs: { - items: createExtensionInput([NavItemBlueprint.dataRefs.target]), - }, - output: [coreExtensionData.reactElement], - factory({ inputs }) { - return [ - coreExtensionData.reactElement( - , + ), + ]; + }, + }), + ], }); /** @@ -152,7 +150,6 @@ export function renderInTestApp( Component: ({ children }) => {children}, }, }), - TestAppNavExtension, ]; if (options?.mountedRoutes) { @@ -183,6 +180,7 @@ export function renderInTestApp( } const features: FrontendFeature[] = [ + appPluginOverride, createExtensionOverrides({ extensions, }), diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index b7fbef8b46..1a302aadc7 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -6,6 +6,7 @@ "role": "backend-plugin", "pluginId": "app", "pluginPackages": [ + "@backstage/plugin-app", "@backstage/plugin-app-backend", "@backstage/plugin-app-node" ] diff --git a/plugins/app-node/package.json b/plugins/app-node/package.json index 4e28a853ed..b8ee9dd437 100644 --- a/plugins/app-node/package.json +++ b/plugins/app-node/package.json @@ -6,6 +6,7 @@ "role": "node-library", "pluginId": "app", "pluginPackages": [ + "@backstage/plugin-app", "@backstage/plugin-app-backend", "@backstage/plugin-app-node" ] diff --git a/plugins/app/.eslintrc.js b/plugins/app/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/app/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/app/README.md b/plugins/app/README.md new file mode 100644 index 0000000000..f54a8e0a4c --- /dev/null +++ b/plugins/app/README.md @@ -0,0 +1,9 @@ +# app + +Welcome to the app plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/app](http://localhost:3000/app). diff --git a/plugins/app/api-report.md b/plugins/app/api-report.md new file mode 100644 index 0000000000..73f5076ef3 --- /dev/null +++ b/plugins/app/api-report.md @@ -0,0 +1,681 @@ +## API Report File for "@backstage/plugin-app" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { AnyApiFactory } from '@backstage/frontend-plugin-api'; +import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; +import { AppTheme } from '@backstage/frontend-plugin-api'; +import { BackstagePlugin } from '@backstage/frontend-plugin-api'; +import { ComponentRef } from '@backstage/frontend-plugin-api'; +import { ComponentType } from 'react'; +import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { ExtensionInput } from '@backstage/frontend-plugin-api'; +import { IconComponent } from '@backstage/core-plugin-api'; +import { IconComponent as IconComponent_2 } from '@backstage/frontend-plugin-api'; +import { JSX as JSX_2 } from 'react'; +import { ReactNode } from 'react'; +import { RouteRef } from '@backstage/frontend-plugin-api'; +import { SignInPageProps } from '@backstage/core-plugin-api'; +import { TranslationMessages } from '@backstage/frontend-plugin-api'; +import { TranslationResource } from '@backstage/frontend-plugin-api'; + +// @public (undocumented) +const appPlugin: BackstagePlugin< + {}, + {}, + { + [x: `component:app/${string}`]: ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + impl: ComponentType; + }, + 'core.component.component', + {} + >; + inputs: { + [x: string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }; + kind: 'component'; + namespace: string; + name: string; + }>; + app: ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + root: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: true; + optional: false; + } + >; + }; + kind: undefined; + namespace: 'app'; + name: undefined; + }>; + 'api:app/app-language': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'app-language'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'app/layout': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + nav: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: true; + optional: false; + } + >; + content: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: true; + optional: false; + } + >; + }; + kind: undefined; + namespace: 'app'; + name: 'layout'; + }>; + 'app/nav': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + items: ExtensionInput< + ConfigurableExtensionDataRef< + { + title: string; + icon: IconComponent; + routeRef: RouteRef; + }, + 'core.nav-item.target', + {} + >, + { + singleton: false; + optional: false; + } + >; + logos: ExtensionInput< + ConfigurableExtensionDataRef< + { + logoIcon?: JSX.Element | undefined; + logoFull?: JSX.Element | undefined; + }, + 'core.nav-logo.logo-elements', + {} + >, + { + singleton: true; + optional: true; + } + >; + }; + kind: undefined; + namespace: 'app'; + name: 'nav'; + }>; + 'app/root': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + router: ExtensionInput< + ConfigurableExtensionDataRef< + ComponentType<{ + children?: ReactNode; + }>, + 'app.router.wrapper', + {} + >, + { + singleton: true; + optional: true; + } + >; + signInPage: ExtensionInput< + ConfigurableExtensionDataRef< + ComponentType, + 'core.sign-in-page.component', + {} + >, + { + singleton: true; + optional: true; + } + >; + children: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: true; + optional: false; + } + >; + elements: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: false; + optional: false; + } + >; + wrappers: ExtensionInput< + ConfigurableExtensionDataRef< + ComponentType<{ + children?: ReactNode; + }>, + 'app.root.wrapper', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: undefined; + namespace: 'app'; + name: 'root'; + }>; + 'app/routes': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + routes: ExtensionInput< + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef + | ConfigurableExtensionDataRef< + RouteRef, + 'core.routing.ref', + { + optional: true; + } + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: undefined; + namespace: 'app'; + name: 'routes'; + }>; + 'api:app/app-theme': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: { + themes: ExtensionInput< + ConfigurableExtensionDataRef, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + namespace: undefined; + name: 'app-theme'; + }>; + 'theme:app/light': ExtensionDefinition<{ + kind: 'theme'; + namespace: 'app'; + name: 'light'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef; + inputs: {}; + }>; + 'theme:app/dark': ExtensionDefinition<{ + kind: 'theme'; + namespace: 'app'; + name: 'dark'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef; + inputs: {}; + }>; + 'api:app/components': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: { + components: ExtensionInput< + ConfigurableExtensionDataRef< + { + ref: ComponentRef; + impl: ComponentType; + }, + 'core.component.component', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + namespace: undefined; + name: 'components'; + }>; + 'api:app/icons': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: { + icons: ExtensionInput< + ConfigurableExtensionDataRef< + { + [x: string]: IconComponent_2; + }, + 'core.icons', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + namespace: undefined; + name: 'icons'; + }>; + 'api:app/feature-flags': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'feature-flags'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/translations': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: { + translations: ExtensionInput< + ConfigurableExtensionDataRef< + | TranslationResource + | TranslationMessages< + string, + { + [x: string]: string; + }, + boolean + >, + 'core.translation.translation', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + namespace: undefined; + name: 'translations'; + }>; + 'app-root-element:app/oauth-request-dialog': ExtensionDefinition<{ + kind: 'app-root-element'; + namespace: 'app'; + name: 'oauth-request-dialog'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: {}; + }>; + 'app-root-element:app/alert-display': ExtensionDefinition<{ + config: { + transientTimeoutMs: number; + anchorOrigin: { + horizontal: 'center' | 'left' | 'right'; + vertical: 'top' | 'bottom'; + }; + }; + configInput: { + anchorOrigin?: + | { + horizontal?: 'center' | 'left' | 'right' | undefined; + vertical?: 'top' | 'bottom' | undefined; + } + | undefined; + transientTimeoutMs?: number | undefined; + }; + output: ConfigurableExtensionDataRef< + JSX_2.Element, + 'core.reactElement', + {} + >; + inputs: { + [x: string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }; + kind: 'app-root-element'; + namespace: 'app'; + name: 'alert-display'; + }>; + 'api:app/discovery': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'discovery'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/alert': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'alert'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/analytics': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'analytics'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/error': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'error'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/storage': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'storage'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/fetch': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'fetch'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/oauth-request': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'oauth-request'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/google-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'google-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/microsoft-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'microsoft-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/github-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'github-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/okta-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'okta-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/gitlab-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'gitlab-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/onelogin-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'onelogin-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/bitbucket-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'bitbucket-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/bitbucket-server-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'bitbucket-server-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/atlassian-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'atlassian-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/vmware-cloud-auth': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'vmware-cloud-auth'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + 'api:app/permission': ExtensionDefinition<{ + kind: 'api'; + namespace: undefined; + name: 'permission'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + AnyApiFactory, + 'core.api.factory', + {} + >; + inputs: {}; + }>; + } +>; +export default appPlugin; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/app/catalog-info.yaml b/plugins/app/catalog-info.yaml new file mode 100644 index 0000000000..5bae243ea7 --- /dev/null +++ b/plugins/app/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-app + title: '@backstage/plugin-app' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: maintainers diff --git a/plugins/app/package.json b/plugins/app/package.json new file mode 100644 index 0000000000..acaaa9fe93 --- /dev/null +++ b/plugins/app/package.json @@ -0,0 +1,65 @@ +{ + "name": "@backstage/plugin-app", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/app" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "app", + "pluginPackages": [ + "@backstage/plugin-app", + "@backstage/plugin-app-backend", + "@backstage/plugin-app-node" + ] + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", + "@backstage/theme": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^15.0.0", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/app/src/defaultApis.ts b/plugins/app/src/defaultApis.ts new file mode 100644 index 0000000000..fd46ecf596 --- /dev/null +++ b/plugins/app/src/defaultApis.ts @@ -0,0 +1,382 @@ +/* + * Copyright 2020 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. + */ + +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + AlertApiForwarder, + NoOpAnalyticsApi, + ErrorApiForwarder, + ErrorAlerter, + GoogleAuth, + GithubAuth, + OktaAuth, + GitlabAuth, + MicrosoftAuth, + BitbucketAuth, + BitbucketServerAuth, + OAuthRequestManager, + WebStorage, + UrlPatternDiscovery, + OneLoginAuth, + UnhandledErrorForwarder, + AtlassianAuth, + createFetchApi, + FetchMiddlewares, + VMwareCloudAuth, +} from '../../../packages/core-app-api/src/apis/implementations'; + +import { + createApiFactory, + alertApiRef, + analyticsApiRef, + errorApiRef, + discoveryApiRef, + fetchApiRef, + identityApiRef, + oauthRequestApiRef, + googleAuthApiRef, + githubAuthApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, + microsoftAuthApiRef, + storageApiRef, + configApiRef, + oneloginAuthApiRef, + bitbucketAuthApiRef, + bitbucketServerAuthApiRef, + atlassianAuthApiRef, + vmwareCloudAuthApiRef, +} from '@backstage/core-plugin-api'; +import { ApiBlueprint } from '@backstage/frontend-plugin-api'; +import { + permissionApiRef, + IdentityPermissionApi, +} from '@backstage/plugin-permission-react'; + +export const apis = [ + ApiBlueprint.make({ + name: 'discovery', + params: { + factory: createApiFactory({ + api: discoveryApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + UrlPatternDiscovery.compile( + `${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`, + ), + }), + }, + }), + ApiBlueprint.make({ + name: 'alert', + params: { + factory: createApiFactory({ + api: alertApiRef, + deps: {}, + factory: () => new AlertApiForwarder(), + }), + }, + }), + ApiBlueprint.make({ + name: 'analytics', + params: { + factory: createApiFactory({ + api: analyticsApiRef, + deps: {}, + factory: () => new NoOpAnalyticsApi(), + }), + }, + }), + ApiBlueprint.make({ + name: 'error', + params: { + factory: createApiFactory({ + api: errorApiRef, + deps: { alertApi: alertApiRef }, + factory: ({ alertApi }) => { + const errorApi = new ErrorAlerter(alertApi, new ErrorApiForwarder()); + UnhandledErrorForwarder.forward(errorApi, { hidden: false }); + return errorApi; + }, + }), + }, + }), + ApiBlueprint.make({ + name: 'storage', + params: { + factory: createApiFactory({ + api: storageApiRef, + deps: { errorApi: errorApiRef }, + factory: ({ errorApi }) => WebStorage.create({ errorApi }), + }), + }, + }), + ApiBlueprint.make({ + name: 'fetch', + params: { + factory: createApiFactory({ + api: fetchApiRef, + deps: { + configApi: configApiRef, + identityApi: identityApiRef, + discoveryApi: discoveryApiRef, + }, + factory: ({ configApi, identityApi, discoveryApi }) => { + return createFetchApi({ + middleware: [ + FetchMiddlewares.resolvePluginProtocol({ + discoveryApi, + }), + FetchMiddlewares.injectIdentityAuth({ + identityApi, + config: configApi, + }), + ], + }); + }, + }), + }, + }), + ApiBlueprint.make({ + name: 'oauth-request', + params: { + factory: createApiFactory({ + api: oauthRequestApiRef, + deps: {}, + factory: () => new OAuthRequestManager(), + }), + }, + }), + ApiBlueprint.make({ + name: 'google-auth', + params: { + factory: createApiFactory({ + api: googleAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GoogleAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'microsoft-auth', + params: { + factory: createApiFactory({ + api: microsoftAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + MicrosoftAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'github-auth', + params: { + factory: createApiFactory({ + api: githubAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GithubAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'okta-auth', + params: { + factory: createApiFactory({ + api: oktaAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OktaAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'gitlab-auth', + params: { + factory: createApiFactory({ + api: gitlabAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GitlabAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'onelogin-auth', + params: { + factory: createApiFactory({ + api: oneloginAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + OneLoginAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'bitbucket-auth', + params: { + factory: createApiFactory({ + api: bitbucketAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + defaultScopes: ['account'], + environment: configApi.getOptionalString('auth.environment'), + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'bitbucket-server-auth', + params: { + factory: createApiFactory({ + api: bitbucketServerAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + BitbucketServerAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + defaultScopes: ['REPO_READ'], + }), + }), + }, + }), + ApiBlueprint.make({ + name: 'atlassian-auth', + params: { + factory: createApiFactory({ + api: atlassianAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return AtlassianAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), + }, + }), + ApiBlueprint.make({ + name: 'vmware-cloud-auth', + params: { + factory: createApiFactory({ + api: vmwareCloudAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => { + return VMwareCloudAuth.create({ + configApi, + discoveryApi, + oauthRequestApi, + environment: configApi.getOptionalString('auth.environment'), + }); + }, + }), + }, + }), + ApiBlueprint.make({ + name: 'permission', + params: { + factory: createApiFactory({ + api: permissionApiRef, + deps: { + discovery: discoveryApiRef, + identity: identityApiRef, + config: configApiRef, + }, + factory: ({ config, discovery, identity }) => + IdentityPermissionApi.create({ config, discovery, identity }), + }), + }, + }), +] as const; diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/plugins/app/src/extensions/App.tsx similarity index 60% rename from packages/frontend-app-api/src/extensions/App.tsx rename to plugins/app/src/extensions/App.tsx index 1eeba30f64..5c6d95bfbc 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/plugins/app/src/extensions/App.tsx @@ -21,6 +21,10 @@ import { createExtension, createExtensionInput, } from '@backstage/frontend-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ApiProvider } from '../../../../packages/core-app-api/src'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppThemeProvider } from '../../../../packages/core-app-api/src/app/AppThemeProvider'; export const App = createExtension({ namespace: 'app', @@ -31,11 +35,17 @@ export const App = createExtension({ }), }, output: [coreExtensionData.reactElement], - factory: ({ node, inputs }) => [ - coreExtensionData.reactElement( - - {inputs.root.get(coreExtensionData.reactElement)} - , - ), - ], + factory: ({ node, apis, inputs }) => { + return [ + coreExtensionData.reactElement( + + + + {inputs.root.get(coreExtensionData.reactElement)} + + + , + ), + ]; + }, }); diff --git a/packages/frontend-app-api/src/extensions/AppLanguageApi.ts b/plugins/app/src/extensions/AppLanguageApi.ts similarity index 90% rename from packages/frontend-app-api/src/extensions/AppLanguageApi.ts rename to plugins/app/src/extensions/AppLanguageApi.ts index ff12848d44..7a6c10db96 100644 --- a/packages/frontend-app-api/src/extensions/AppLanguageApi.ts +++ b/plugins/app/src/extensions/AppLanguageApi.ts @@ -15,7 +15,7 @@ */ // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppLanguageSelector } from '../../../core-app-api/src/apis/implementations/AppLanguageApi'; +import { AppLanguageSelector } from '../../../../packages/core-app-api/src/apis/implementations/AppLanguageApi'; import { appLanguageApiRef } from '@backstage/core-plugin-api/alpha'; import { ApiBlueprint, createApiFactory } from '@backstage/frontend-plugin-api'; diff --git a/packages/frontend-app-api/src/extensions/AppLayout.tsx b/plugins/app/src/extensions/AppLayout.tsx similarity index 100% rename from packages/frontend-app-api/src/extensions/AppLayout.tsx rename to plugins/app/src/extensions/AppLayout.tsx diff --git a/packages/frontend-app-api/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx similarity index 95% rename from packages/frontend-app-api/src/extensions/AppNav.tsx rename to plugins/app/src/extensions/AppNav.tsx index b80a537ef6..3659f474b2 100644 --- a/packages/frontend-app-api/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -33,9 +33,9 @@ import { SidebarItem, } from '@backstage/core-components'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import LogoIcon from '../../../app/src/components/Root/LogoIcon'; +import LogoIcon from '../../../../packages/app/src/components/Root/LogoIcon'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import LogoFull from '../../../app/src/components/Root/LogoFull'; +import LogoFull from '../../../../packages/app/src/components/Root/LogoFull'; const useSidebarLogoStyles = makeStyles({ root: { diff --git a/packages/frontend-app-api/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx similarity index 69% rename from packages/frontend-app-api/src/extensions/AppRoot.tsx rename to plugins/app/src/extensions/AppRoot.tsx index a158386d04..bd786765f9 100644 --- a/packages/frontend-app-api/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -19,7 +19,6 @@ import React, { Fragment, PropsWithChildren, ReactNode, - useContext, useState, } from 'react'; import { @@ -27,21 +26,31 @@ import { RouterBlueprint, SignInPageBlueprint, coreExtensionData, + discoveryApiRef, + fetchApiRef, + errorApiRef, createExtension, createExtensionInput, + routeResolutionApiRef, } from '@backstage/frontend-plugin-api'; import { + DiscoveryApi, + ErrorApi, + FetchApi, IdentityApi, + ProfileInfo, SignInPageProps, configApiRef, + identityApiRef, useApi, } from '@backstage/core-plugin-api'; -import { InternalAppContext } from '../wiring/InternalAppContext'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; +import { isProtectedApp } from '../../../../packages/core-app-api/src/app/isProtectedApp'; import { BrowserRouter } from 'react-router-dom'; -import { RouteTracker } from '../routing/RouteTracker'; -import { getBasePath } from '../routing/getBasePath'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { RouteTracker } from '../../../../packages/frontend-app-api/src/routing/RouteTracker'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { getBasePath } from '../../../../packages/frontend-app-api/src/routing/getBasePath'; export const AppRoot = createExtension({ namespace: 'app', @@ -65,7 +74,28 @@ export const AppRoot = createExtension({ ]), }, output: [coreExtensionData.reactElement], - factory({ inputs }) { + factory({ inputs, apis }) { + if (isProtectedApp()) { + const identityApi = apis.get(identityApiRef); + if (!identityApi) { + throw new Error('App requires an Identity API implementation'); + } + const appIdentityProxy = toAppIdentityProxy(identityApi); + const discoveryApi = apis.get(discoveryApiRef); + const errorApi = apis.get(errorApiRef); + const fetchApi = apis.get(fetchApiRef); + if (!discoveryApi || !errorApi || !fetchApi) { + throw new Error( + 'App is running in protected mode but missing required APIs', + ); + } + appIdentityProxy.enableCookieAuth({ + discoveryApi, + errorApi, + fetchApi, + }); + } + let content: React.ReactNode = ( <> {inputs.elements.map(el => ( @@ -123,6 +153,33 @@ function SignInPageWrapper({ return <>{children}; } +type AppIdentityProxy = IdentityApi & { + enableCookieAuth(ctx: { + errorApi: ErrorApi; + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + }): void; + setTarget( + impl: IdentityApi & /* backwards compat stuff */ { + getUserId?(): string; + getIdToken?(): Promise; + getProfile?(): ProfileInfo; + }, + options: { signOutTargetUrl: string }, + ): void; +}; + +function toAppIdentityProxy(identityApi: IdentityApi): AppIdentityProxy { + if (!('enableCookieAuth' in identityApi)) { + throw new Error('Unexpected Identity API implementation'); + } + return identityApi as AppIdentityProxy; +} + +type RouteResolverProxy = { + getRouteObjects(): any[]; +}; + /** * Props for the {@link AppRouter} component. * @public @@ -157,12 +214,17 @@ export function AppRouter(props: AppRouterProps) { } = props; const configApi = useApi(configApiRef); + const appIdentityProxy = toAppIdentityProxy(useApi(identityApiRef)); + const routeResolutionsApi = useApi(routeResolutionApiRef); const basePath = getBasePath(configApi); - const internalAppContext = useContext(InternalAppContext); - if (!internalAppContext) { - throw new Error('AppRouter must be rendered within the AppProvider'); + + // TODO: Private access for now, probably replace with path -> node lookup method on the API + if (!('getRouteObjects' in routeResolutionsApi)) { + throw new Error('Unexpected route resolution API implementation'); } - const { routeObjects, appIdentityProxy } = internalAppContext; + const routeObjects = ( + routeResolutionsApi as RouteResolverProxy + ).getRouteObjects(); // If the app hasn't configured a sign-in page, we just continue as guest. if (!SignInPageComponent) { diff --git a/packages/frontend-app-api/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx similarity index 100% rename from packages/frontend-app-api/src/extensions/AppRoutes.tsx rename to plugins/app/src/extensions/AppRoutes.tsx diff --git a/packages/frontend-app-api/src/extensions/AppThemeApi.tsx b/plugins/app/src/extensions/AppThemeApi.tsx similarity index 93% rename from packages/frontend-app-api/src/extensions/AppThemeApi.tsx rename to plugins/app/src/extensions/AppThemeApi.tsx index fbbb9957ff..a31e29e58b 100644 --- a/packages/frontend-app-api/src/extensions/AppThemeApi.tsx +++ b/plugins/app/src/extensions/AppThemeApi.tsx @@ -28,7 +28,8 @@ import { createApiFactory, appThemeApiRef, } from '@backstage/frontend-plugin-api'; -import { AppThemeSelector } from '@backstage/core-app-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppThemeSelector } from '../../../../packages/core-app-api/src/apis/implementations'; /** * Contains the themes installed into the app. diff --git a/packages/frontend-app-api/src/extensions/ComponentsApi.tsx b/plugins/app/src/extensions/ComponentsApi.tsx similarity index 88% rename from packages/frontend-app-api/src/extensions/ComponentsApi.tsx rename to plugins/app/src/extensions/ComponentsApi.tsx index 5489aa35a2..b578429253 100644 --- a/packages/frontend-app-api/src/extensions/ComponentsApi.tsx +++ b/plugins/app/src/extensions/ComponentsApi.tsx @@ -21,7 +21,8 @@ import { createApiFactory, componentsApiRef, } from '@backstage/frontend-plugin-api'; -import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { DefaultComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/ComponentsApi'; /** * Contains the shareable components installed into the app. diff --git a/packages/frontend-app-api/src/extensions/FeatureFlagsApi.ts b/plugins/app/src/extensions/FeatureFlagsApi.ts similarity index 88% rename from packages/frontend-app-api/src/extensions/FeatureFlagsApi.ts rename to plugins/app/src/extensions/FeatureFlagsApi.ts index 8016521539..40a44d1348 100644 --- a/packages/frontend-app-api/src/extensions/FeatureFlagsApi.ts +++ b/plugins/app/src/extensions/FeatureFlagsApi.ts @@ -20,7 +20,7 @@ import { featureFlagsApiRef, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { LocalStorageFeatureFlags } from '../../../core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags'; +import { LocalStorageFeatureFlags } from '../../../../packages/core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags'; /** * Contains the shareable icons installed into the app. diff --git a/packages/frontend-app-api/src/extensions/IconsApi.ts b/plugins/app/src/extensions/IconsApi.ts similarity index 84% rename from packages/frontend-app-api/src/extensions/IconsApi.ts rename to plugins/app/src/extensions/IconsApi.ts index dc2acac629..e73a74132a 100644 --- a/packages/frontend-app-api/src/extensions/IconsApi.ts +++ b/plugins/app/src/extensions/IconsApi.ts @@ -21,9 +21,10 @@ import { createApiFactory, iconsApiRef, } from '@backstage/frontend-plugin-api'; -import { DefaultIconsApi } from '../apis/implementations/IconsApi'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { icons as defaultIcons } from '../../../app-defaults/src/defaults'; +import { DefaultIconsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/IconsApi'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { icons as defaultIcons } from '../../../../packages/app-defaults/src/defaults'; /** * Contains the shareable icons installed into the app. diff --git a/packages/frontend-app-api/src/extensions/TranslationsApi.tsx b/plugins/app/src/extensions/TranslationsApi.tsx similarity index 92% rename from packages/frontend-app-api/src/extensions/TranslationsApi.tsx rename to plugins/app/src/extensions/TranslationsApi.tsx index 1fa4fadaae..ed7ab1abdb 100644 --- a/packages/frontend-app-api/src/extensions/TranslationsApi.tsx +++ b/plugins/app/src/extensions/TranslationsApi.tsx @@ -25,7 +25,7 @@ import { } from '@backstage/core-plugin-api/alpha'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi'; +import { I18nextTranslationApi } from '../../../../packages/core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi'; /** * Contains translations that are installed in the app. diff --git a/packages/frontend-app-api/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx similarity index 92% rename from packages/frontend-app-api/src/extensions/components.tsx rename to plugins/app/src/extensions/components.tsx index 4f6574ab3e..829cb99ea2 100644 --- a/packages/frontend-app-api/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -24,8 +24,7 @@ import { } from '@backstage/frontend-plugin-api'; import { ErrorPanel } from '@backstage/core-components'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { components as defaultComponents } from '../../../app-defaults/src/defaults'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { components as defaultComponents } from '../../../../packages/app-defaults/src/defaults'; export const DefaultProgressComponent = createComponentExtension({ ref: coreComponentRefs.progress, diff --git a/packages/frontend-app-api/src/extensions/elements.tsx b/plugins/app/src/extensions/elements.tsx similarity index 100% rename from packages/frontend-app-api/src/extensions/elements.tsx rename to plugins/app/src/extensions/elements.tsx diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts new file mode 100644 index 0000000000..157f7ddb62 --- /dev/null +++ b/plugins/app/src/extensions/index.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { App } from './App'; +export { AppLanguageApi } from './AppLanguageApi'; +export { AppLayout } from './AppLayout'; +export { AppNav } from './AppNav'; +export { AppRoot } from './AppRoot'; +export { AppRoutes } from './AppRoutes'; +export { AppThemeApi, DarkTheme, LightTheme } from './AppThemeApi'; +export { ComponentsApi } from './ComponentsApi'; +export { IconsApi } from './IconsApi'; +export { FeatureFlagsApi } from './FeatureFlagsApi'; +export { TranslationsApi } from './TranslationsApi'; +export { + DefaultProgressComponent, + DefaultErrorBoundaryComponent, + DefaultNotFoundErrorPageComponent, +} from './components'; +export { + oauthRequestDialogAppRootElement, + alertDisplayAppRootElement, +} from './elements'; diff --git a/plugins/app/src/index.ts b/plugins/app/src/index.ts new file mode 100644 index 0000000000..24f65df618 --- /dev/null +++ b/plugins/app/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { appPlugin as default } from './plugin'; diff --git a/plugins/app/src/plugin.test.ts b/plugins/app/src/plugin.test.ts new file mode 100644 index 0000000000..80588710a5 --- /dev/null +++ b/plugins/app/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { appPlugin } from './plugin'; + +describe('app', () => { + it('should export plugin', () => { + expect(appPlugin).toBeDefined(); + }); +}); diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts new file mode 100644 index 0000000000..ff0c69faec --- /dev/null +++ b/plugins/app/src/plugin.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { + App, + AppLanguageApi, + AppLayout, + AppNav, + AppRoot, + AppRoutes, + AppThemeApi, + DarkTheme, + LightTheme, + ComponentsApi, + IconsApi, + FeatureFlagsApi, + TranslationsApi, + DefaultProgressComponent, + DefaultNotFoundErrorPageComponent, + DefaultErrorBoundaryComponent, + oauthRequestDialogAppRootElement, + alertDisplayAppRootElement, +} from './extensions'; +import { apis } from './defaultApis'; + +/** @public */ +export const appPlugin = createFrontendPlugin({ + id: 'app', + extensions: [ + ...apis, + App, + AppLanguageApi, + AppLayout, + AppNav, + AppRoot, + AppRoutes, + AppThemeApi, + DarkTheme, + LightTheme, + ComponentsApi, + IconsApi, + FeatureFlagsApi, + TranslationsApi, + DefaultProgressComponent, + DefaultNotFoundErrorPageComponent, + DefaultErrorBoundaryComponent, + oauthRequestDialogAppRootElement, + alertDisplayAppRootElement, + ], +}); diff --git a/plugins/app/src/setupTests.ts b/plugins/app/src/setupTests.ts new file mode 100644 index 0000000000..658016ffdd --- /dev/null +++ b/plugins/app/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index 963bc40c9c..d3d76396e5 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -60,6 +60,8 @@ const roleRules = [ targetRole: 'frontend-plugin', except: [ // TODO(freben): Address these + '@backstage/frontend-app-api', + '@backstage/frontend-test-utils', '@backstage/plugin-api-docs', '@backstage/plugin-techdocs-addons-test-utils', ], diff --git a/yarn.lock b/yarn.lock index 08d8bdf77c..1e5bf67573 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4438,6 +4438,7 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-app": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^" @@ -4488,6 +4489,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-app": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@testing-library/jest-dom": ^6.0.0 @@ -4682,6 +4684,34 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-app@workspace:^, @backstage/plugin-app@workspace:plugins/app": + version: 0.0.0-use.local + resolution: "@backstage/plugin-app@workspace:plugins/app" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-test-utils": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.61 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^15.0.0 + "@testing-library/user-event": ^14.0.0 + "@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0 + msw: ^1.0.0 + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-atlassian-provider@workspace:^, @backstage/plugin-auth-backend-module-atlassian-provider@workspace:plugins/auth-backend-module-atlassian-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-atlassian-provider@workspace:plugins/auth-backend-module-atlassian-provider" @@ -26731,6 +26761,7 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/integration-react": "workspace:^" "@backstage/plugin-api-docs": "workspace:^" + "@backstage/plugin-app": "workspace:^" "@backstage/plugin-app-visualizer": "workspace:^" "@backstage/plugin-auth-react": "workspace:^" "@backstage/plugin-catalog": "workspace:^" @@ -30324,9 +30355,9 @@ __metadata: linkType: hard "is-map@npm:^2.0.1": - version: 2.0.2 - resolution: "is-map@npm:2.0.2" - checksum: ace3d0ecd667bbdefdb1852de601268f67f2db725624b1958f279316e13fecb8fa7df91fd60f690d7417b4ec180712f5a7ee967008e27c65cfd475cc84337728 + version: 2.0.3 + resolution: "is-map@npm:2.0.3" + checksum: e6ce5f6380f32b141b3153e6ba9074892bbbbd655e92e7ba5ff195239777e767a976dcd4e22f864accaf30e53ebf961ab1995424aef91af68788f0591b7396cc languageName: node linkType: hard @@ -30541,9 +30572,9 @@ __metadata: linkType: hard "is-set@npm:^2.0.1": - version: 2.0.2 - resolution: "is-set@npm:2.0.2" - checksum: b64343faf45e9387b97a6fd32be632ee7b269bd8183701f3b3f5b71a7cf00d04450ed8669d0bd08753e08b968beda96fca73a10fd0ff56a32603f64deba55a57 + version: 2.0.3 + resolution: "is-set@npm:2.0.3" + checksum: 36e3f8c44bdbe9496c9689762cc4110f6a6a12b767c5d74c0398176aa2678d4467e3bf07595556f2dba897751bde1422480212b97d973c7b08a343100b0c0dfe languageName: node linkType: hard @@ -35645,12 +35676,12 @@ __metadata: linkType: hard "object-is@npm:^1.0.1": - version: 1.1.5 - resolution: "object-is@npm:1.1.5" + version: 1.1.6 + resolution: "object-is@npm:1.1.6" dependencies: - call-bind: ^1.0.2 - define-properties: ^1.1.3 - checksum: 989b18c4cba258a6b74dc1d74a41805c1a1425bce29f6cabb50dcb1a6a651ea9104a1b07046739a49a5bb1bc49727bcb00efd5c55f932f6ea04ec8927a7901fe + call-bind: ^1.0.7 + define-properties: ^1.2.1 + checksum: 3ea22759967e6f2380a2cbbd0f737b42dc9ddb2dfefdb159a1b927fea57335e1b058b564bfa94417db8ad58cddab33621a035de6f5e5ad56d89f2dd03e66c6a1 languageName: node linkType: hard @@ -39044,7 +39075,7 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.0.2": +"react@npm:^16.13.1 || ^17.0.0 || ^18.0.0, react@npm:^18.0.2": version: 18.3.1 resolution: "react@npm:18.3.1" dependencies: