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())
+ .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(
-
-
- {inputs.items.map((item, index) => {
- const { icon, title, routeRef } = item.get(
- NavItemBlueprint.dataRefs.target,
- );
+const appPluginOverride = appPlugin.withOverrides({
+ extensions: [
+ appPlugin.getExtension('app/nav').override({
+ output: [coreExtensionData.reactElement],
+ factory(_originalFactory, { inputs }) {
+ return [
+ coreExtensionData.reactElement(
+
+
+ {inputs.items.map((item, index) => {
+ const { icon, title, routeRef } = item.get(
+ NavItemBlueprint.dataRefs.target,
+ );
- return (
-
- );
- })}
-
- ,
- ),
- ];
- },
+ return (
+
+ );
+ })}
+
+ ,
+ ),
+ ];
+ },
+ }),
+ ],
});
/**
@@ -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: