diff --git a/.changeset/brown-books-carry.md b/.changeset/brown-books-carry.md
new file mode 100644
index 0000000000..1299354445
--- /dev/null
+++ b/.changeset/brown-books-carry.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': patch
+---
+
+Create a core components extension that allows adopters to override core app components such as `Progress`, `BootErrorPage`, `NotFoundErrorPage` and `ErrorBoundaryFallback`.
diff --git a/.changeset/clever-wombats-sneeze.md b/.changeset/clever-wombats-sneeze.md
new file mode 100644
index 0000000000..ffd94aa9f0
--- /dev/null
+++ b/.changeset/clever-wombats-sneeze.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Create factories for overriding default core components extensions.
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index 67faf8d646..791d2b38fa 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -17,6 +17,7 @@
import React from 'react';
import { createApp } from '@backstage/frontend-app-api';
import { pagesPlugin } from './examples/pagesPlugin';
+import { CustomNotFoundErrorPage } from './examples/notFoundErrorPageExtension';
import graphiqlPlugin from '@backstage/plugin-graphiql/alpha';
import techRadarPlugin from '@backstage/plugin-tech-radar/alpha';
import userSettingsPlugin from '@backstage/plugin-user-settings/alpha';
@@ -32,7 +33,10 @@ import {
} from '@backstage/frontend-plugin-api';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { homePage } from './HomePage';
-import { collectLegacyRoutes } from '@backstage/core-compat-api';
+import {
+ collectLegacyComponents,
+ collectLegacyRoutes,
+} from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
import { Route } from 'react-router';
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
@@ -114,6 +118,10 @@ const collectedLegacyPlugins = collectLegacyRoutes(
,
);
+const legacyAppComponents = collectLegacyComponents({
+ NotFoundErrorPage: CustomNotFoundErrorPage,
+});
+
const app = createApp({
features: [
graphiqlPlugin,
@@ -129,6 +137,7 @@ const app = createApp({
scmAuthExtension,
scmIntegrationApi,
signInPage,
+ ...legacyAppComponents,
],
}),
],
diff --git a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx
new file mode 100644
index 0000000000..82acc25fb5
--- /dev/null
+++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ createComponentExtension,
+ coreComponentsRefs,
+} from '@backstage/frontend-plugin-api';
+import { Box, Typography } from '@material-ui/core';
+import { Button } from '@backstage/core-components';
+
+export function CustomNotFoundErrorPage() {
+ return (
+
+ 404
+
+ Bowie was unable to locate this page. Please contact your support team
+ if this page used to exist.
+
+
+
+
+ );
+}
+
+export default createComponentExtension({
+ ref: coreComponentsRefs.notFoundErrorPage,
+ component: { sync: () => CustomNotFoundErrorPage },
+});
diff --git a/packages/core-compat-api/api-report.md b/packages/core-compat-api/api-report.md
index 877aefa24f..866517b5c8 100644
--- a/packages/core-compat-api/api-report.md
+++ b/packages/core-compat-api/api-report.md
@@ -6,7 +6,9 @@
///
import { AnyRouteRefParams } from '@backstage/core-plugin-api';
+import { AppComponents } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
+import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { ExternalRouteRef as ExternalRouteRef_2 } from '@backstage/frontend-plugin-api';
@@ -16,6 +18,11 @@ import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
import { SubRouteRef as SubRouteRef_2 } from '@backstage/frontend-plugin-api';
+// @public (undocumented)
+export function collectLegacyComponents(
+ components: Partial,
+): Extension[];
+
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
diff --git a/packages/core-compat-api/src/collectLegacyComponents.test.tsx b/packages/core-compat-api/src/collectLegacyComponents.test.tsx
new file mode 100644
index 0000000000..0600f58cf8
--- /dev/null
+++ b/packages/core-compat-api/src/collectLegacyComponents.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+import { collectLegacyComponents } from './collectLegacyComponents';
+
+describe('collectLegacyComponents', () => {
+ const components = {
+ Progress: () =>
Progress
,
+ BootErrorPage: () =>
BootErrorPage
,
+ NotFoundErrorPage: () =>
NotFoundErrorPage
,
+ ErrorBoundaryFallback: () =>
ErrorBoundaryFallback
,
+ };
+
+ it('should collect legacy routes', () => {
+ const collected = collectLegacyComponents(components);
+
+ expect(
+ collected.map(p => ({
+ id: p.id,
+ attachTo: p.attachTo,
+ disabled: p.disabled,
+ })),
+ ).toEqual([
+ {
+ id: 'core.components.progress',
+ attachTo: { id: 'core', input: 'components' },
+ disabled: false,
+ },
+ {
+ id: 'core.components.bootErrorPage',
+ attachTo: { id: 'core', input: 'components' },
+ disabled: false,
+ },
+ {
+ id: 'core.components.notFoundErrorPage',
+ attachTo: { id: 'core', input: 'components' },
+ disabled: false,
+ },
+ {
+ id: 'core.components.errorBoundaryFallback',
+ attachTo: { id: 'core', input: 'components' },
+ disabled: false,
+ },
+ ]);
+ });
+});
diff --git a/packages/core-compat-api/src/collectLegacyComponents.tsx b/packages/core-compat-api/src/collectLegacyComponents.tsx
new file mode 100644
index 0000000000..6092ca1be7
--- /dev/null
+++ b/packages/core-compat-api/src/collectLegacyComponents.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ Extension,
+ ComponentRef,
+ createComponentExtension,
+ coreComponentsRefs,
+} from '@backstage/frontend-plugin-api';
+import { AppComponents } from '@backstage/core-plugin-api';
+
+type ComponentTypes = T[keyof T];
+
+const refs: Record> = {
+ Progress: coreComponentsRefs.progress,
+ BootErrorPage: coreComponentsRefs.bootErrorPage,
+ NotFoundErrorPage: coreComponentsRefs.notFoundErrorPage,
+ ErrorBoundaryFallback: coreComponentsRefs.errorBoundaryFallback,
+};
+
+/** @public */
+export function collectLegacyComponents(components: Partial) {
+ return Object.entries(components).reduce[]>(
+ (extensions, [name, component]) => {
+ const ref = refs[name];
+ return ref
+ ? extensions.concat(
+ createComponentExtension({
+ ref,
+ component: { sync: () => component },
+ }),
+ )
+ : extensions;
+ },
+ [],
+ );
+}
diff --git a/packages/core-compat-api/src/index.ts b/packages/core-compat-api/src/index.ts
index 421ced7054..d31513e559 100644
--- a/packages/core-compat-api/src/index.ts
+++ b/packages/core-compat-api/src/index.ts
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
+export { collectLegacyComponents } from './collectLegacyComponents';
export { convertLegacyApp } from './convertLegacyApp';
export { convertLegacyRouteRef } from './convertLegacyRouteRef';
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts
new file mode 100644
index 0000000000..50d125e4ab
--- /dev/null
+++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/ComponentsApi.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentRef, ComponentsApi } from '@backstage/frontend-plugin-api';
+
+/**
+ * Implementation for the {@linkComponentApi}
+ *
+ * @internal
+ */
+export class DefaultComponentsApi implements ComponentsApi {
+ #components: Map, any>;
+
+ constructor(components: Map, any>) {
+ this.#components = components;
+ }
+
+ getComponent(ref: ComponentRef): T {
+ const impl = this.#components.get(ref);
+ if (!impl) {
+ throw new Error(`No implementation found for component ref ${ref}`);
+ }
+ return impl;
+ }
+}
diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts
new file mode 100644
index 0000000000..f04059c54f
--- /dev/null
+++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { DefaultComponentsApi } from './ComponentsApi';
diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx
index 60772d435c..441cb5bcf0 100644
--- a/packages/frontend-app-api/src/extensions/Core.tsx
+++ b/packages/frontend-app-api/src/extensions/Core.tsx
@@ -30,6 +30,9 @@ export const Core = createExtension({
themes: createExtensionInput({
theme: coreExtensionData.theme,
}),
+ components: createExtensionInput({
+ component: coreExtensionData.component,
+ }),
root: createExtensionInput(
{
element: coreExtensionData.reactElement,
diff --git a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
index 985fd07be9..9acd27b6bf 100644
--- a/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreRoutes.tsx
@@ -19,6 +19,9 @@ import {
createExtension,
coreExtensionData,
createExtensionInput,
+ coreComponentsRefs,
+ useApi,
+ componentsApiRef,
} from '@backstage/frontend-plugin-api';
import { useRoutes } from 'react-router-dom';
@@ -37,12 +40,21 @@ export const CoreRoutes = createExtension({
},
factory({ inputs }) {
const Routes = () => {
- const element = useRoutes(
- inputs.routes.map(route => ({
+ const componentsApi = useApi(componentsApiRef);
+ const NotFoundErrorPage = componentsApi.getComponent(
+ coreComponentsRefs.notFoundErrorPage,
+ );
+
+ const element = useRoutes([
+ ...inputs.routes.map(route => ({
path: `${route.path}/*`,
element: route.element,
})),
- );
+ {
+ path: '*',
+ element: ,
+ },
+ ]);
return element;
};
diff --git a/packages/frontend-app-api/src/extensions/components.tsx b/packages/frontend-app-api/src/extensions/components.tsx
new file mode 100644
index 0000000000..fa9187a831
--- /dev/null
+++ b/packages/frontend-app-api/src/extensions/components.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createComponentExtension,
+ coreComponentsRefs,
+} from '@backstage/frontend-plugin-api';
+// 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
+
+export const DefaultProgressComponent = createComponentExtension({
+ ref: coreComponentsRefs.progress,
+ component: { sync: () => defaultComponents.Progress },
+});
+
+export const DefaultBootErrorPageComponent = createComponentExtension({
+ ref: coreComponentsRefs.bootErrorPage,
+ component: { sync: () => defaultComponents.BootErrorPage },
+});
+
+export const DefaultNotFoundErrorPageComponent = createComponentExtension({
+ ref: coreComponentsRefs.notFoundErrorPage,
+ component: { sync: () => defaultComponents.NotFoundErrorPage },
+});
+
+export const DefaultErrorBoundaryComponent = createComponentExtension({
+ ref: coreComponentsRefs.errorBoundaryFallback,
+ component: { sync: () => defaultComponents.ErrorBoundaryFallback },
+});
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
index c94a06a442..a06d236b77 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
@@ -30,11 +30,7 @@ import {
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { createAppTree } from '../tree';
-import { Core } from '../extensions/Core';
-import { CoreRoutes } from '../extensions/CoreRoutes';
-import { CoreNav } from '../extensions/CoreNav';
-import { CoreLayout } from '../extensions/CoreLayout';
-import { CoreRouter } from '../extensions/CoreRouter';
+import { builtinExtensions } from '../wiring/createApp';
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -80,8 +76,8 @@ function routeInfoFromExtensions(extensions: Extension[]) {
extensions,
});
const tree = createAppTree({
+ builtinExtensions,
config: new MockConfigApi({}),
- builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout, CoreRouter],
features: [plugin],
});
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index 41d722a68e..232de74de4 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -199,6 +199,12 @@ describe('createApp', () => {
]
]
+ components [
+
+
+
+
+ ]
themes [
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index a1412f1c3c..4e463fb526 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -20,6 +20,8 @@ import {
AppTree,
appTreeApiRef,
BackstagePlugin,
+ ComponentRef,
+ componentsApiRef,
coreExtensionData,
ExtensionDataRef,
ExtensionOverrides,
@@ -89,6 +91,12 @@ import { RoutingProvider } from '../routing/RoutingProvider';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { createAppTree } from '../tree';
+import {
+ DefaultProgressComponent,
+ DefaultErrorBoundaryComponent,
+ DefaultBootErrorPageComponent,
+ DefaultNotFoundErrorPageComponent,
+} from '../extensions/components';
import { AppNode } from '@backstage/frontend-plugin-api';
import { toLegacyPlugin } from '../routing/toLegacyPlugin';
import { InternalAppContext } from './InternalAppContext';
@@ -97,13 +105,18 @@ import { CoreRouter } from '../extensions/CoreRouter';
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
+import { DefaultComponentsApi } from '../apis/implementations/ComponentsApi';
-const builtinExtensions = [
+export const builtinExtensions = [
Core,
CoreRouter,
CoreRoutes,
CoreNav,
CoreLayout,
+ DefaultProgressComponent,
+ DefaultErrorBoundaryComponent,
+ DefaultBootErrorPageComponent,
+ DefaultNotFoundErrorPageComponent,
LightTheme,
DarkTheme,
];
@@ -425,6 +438,24 @@ function createApiHolder(
}),
});
+ const componentsExtensions =
+ tree.root.edges.attachments
+ .get('components')
+ ?.map(e => e.instance?.getData(coreExtensionData.component))
+ .filter(x => !!x) ?? [];
+
+ const componentsMap = componentsExtensions.reduce(
+ (components, component) =>
+ component ? components.set(component.ref, component?.impl) : components,
+ new Map, any>(),
+ );
+
+ factoryRegistry.register('static', {
+ api: componentsApiRef,
+ deps: {},
+ factory: () => new DefaultComponentsApi(componentsMap),
+ });
+
factoryRegistry.register('static', {
api: appThemeApiRef,
deps: {},
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index c1c920559e..e1ed76d82c 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -22,6 +22,7 @@ import { AuthProviderInfo } from '@backstage/core-plugin-api';
import { AuthRequestOptions } from '@backstage/core-plugin-api';
import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstageIdentityResponse } from '@backstage/core-plugin-api';
+import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { bitbucketAuthApiRef } from '@backstage/core-plugin-api';
import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api';
@@ -64,6 +65,7 @@ import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { PendingOAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
+import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SessionApi } from '@backstage/core-plugin-api';
@@ -284,6 +286,21 @@ export type CommonAnalyticsContext = {
extensionId: string;
};
+// @public (undocumented)
+export type ComponentRef = {
+ id: string;
+ T: T;
+};
+
+// @public
+export interface ComponentsApi {
+ // (undocumented)
+ getComponent(ref: ComponentRef): T;
+}
+
+// @public
+export const componentsApiRef: ApiRef;
+
export { ConfigApi };
export { configApiRef };
@@ -304,6 +321,31 @@ export interface ConfigurableExtensionDataRef<
>;
}
+// @public (undocumented)
+export type CoreBootErrorPageComponent = ComponentType<
+ PropsWithChildren<{
+ step: 'load-config' | 'load-chunk';
+ error: Error;
+ }>
+>;
+
+// @public (undocumented)
+export const coreComponentsRefs: {
+ progress: ComponentRef;
+ bootErrorPage: ComponentRef;
+ notFoundErrorPage: ComponentRef;
+ errorBoundaryFallback: ComponentRef;
+};
+
+// @public (undocumented)
+export type CoreErrorBoundaryFallbackComponent = ComponentType<
+ PropsWithChildren<{
+ plugin?: BackstagePlugin_2;
+ error: Error;
+ resetError: () => void;
+ }>
+>;
+
// @public (undocumented)
export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef;
@@ -313,8 +355,23 @@ export const coreExtensionData: {
navTarget: ConfigurableExtensionDataRef;
theme: ConfigurableExtensionDataRef;
logoElements: ConfigurableExtensionDataRef;
+ component: ConfigurableExtensionDataRef<
+ {
+ ref: ComponentRef>;
+ impl: ComponentType;
+ },
+ {}
+ >;
};
+// @public (undocumented)
+export type CoreNotFoundErrorPageComponent = ComponentType<
+ PropsWithChildren<{}>
+>;
+
+// @public (undocumented)
+export type CoreProgressComponent = ComponentType>;
+
// @public (undocumented)
export function createApiExtension<
TConfig extends {},
@@ -341,6 +398,31 @@ export { createApiFactory };
export { createApiRef };
+// @public (undocumented)
+export function createComponentExtension<
+ TRef extends ComponentRef,
+ TConfig extends {},
+ TInputs extends AnyExtensionInputMap,
+>(options: {
+ ref: TRef;
+ disabled?: boolean;
+ inputs?: TInputs;
+ configSchema?: PortableSchema;
+ component:
+ | {
+ lazy: (values: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => Promise;
+ }
+ | {
+ sync: (values: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => TRef['T'];
+ };
+}): Extension;
+
// @public (undocumented)
export function createExtension<
TOutput extends AnyExtensionDataMap,
diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
new file mode 100644
index 0000000000..b55ed96549
--- /dev/null
+++ b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createApiRef } from '@backstage/core-plugin-api';
+import { ComponentRef } from '../../components';
+
+/**
+ * API for looking up components based on component refs.
+ *
+ * @public
+ */
+export interface ComponentsApi {
+ // TODO: Should component refs also provide the default implementation so that we're guaranteed to get a component?
+ getComponent(ref: ComponentRef): T;
+}
+
+/**
+ * The `ApiRef` of {@link ComponentsApi}.
+ *
+ * @public
+ */
+export const componentsApiRef = createApiRef({
+ id: 'core.components',
+});
diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts
index 8791912e4a..f2496ee61a 100644
--- a/packages/frontend-plugin-api/src/apis/definitions/index.ts
+++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts
@@ -34,6 +34,7 @@ export * from './auth';
export * from './AlertApi';
export * from './AppThemeApi';
+export * from './ComponentsApi';
export * from './ConfigApi';
export * from './DiscoveryApi';
export * from './ErrorApi';
diff --git a/packages/frontend-plugin-api/src/components/ComponentRef.tsx b/packages/frontend-plugin-api/src/components/ComponentRef.tsx
new file mode 100644
index 0000000000..2db85b9a42
--- /dev/null
+++ b/packages/frontend-plugin-api/src/components/ComponentRef.tsx
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ CoreBootErrorPageComponent,
+ CoreErrorBoundaryFallbackComponent,
+ CoreNotFoundErrorPageComponent,
+ CoreProgressComponent,
+} from '../types';
+
+/** @public */
+export type ComponentRef = {
+ id: string;
+ T: T;
+};
+
+/** @public */
+export function createComponentRef(options: {
+ id: string;
+}): ComponentRef {
+ const { id } = options;
+ return {
+ id,
+ get T(): T {
+ throw new Error(`tried to read ComponentRef.T of ${id}`);
+ },
+ };
+}
+
+const coreProgressComponentRef = createComponentRef({
+ id: 'core.components.progress',
+});
+
+const coreBootErrorPageComponentRef =
+ createComponentRef({
+ id: 'core.components.bootErrorPage',
+ });
+
+const coreNotFoundErrorPageComponentRef =
+ createComponentRef({
+ id: 'core.components.notFoundErrorPage',
+ });
+
+const coreErrorBoundaryFallbackComponentRef =
+ createComponentRef({
+ id: 'core.components.errorBoundaryFallback',
+ });
+
+/** @public */
+export const coreComponentsRefs = {
+ progress: coreProgressComponentRef,
+ bootErrorPage: coreBootErrorPageComponentRef,
+ notFoundErrorPage: coreNotFoundErrorPageComponentRef,
+ errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef,
+};
diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
index 8fad8d8336..c9fa75c7fa 100644
--- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
+++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx
@@ -14,10 +14,14 @@
* limitations under the License.
*/
-import React, { PropsWithChildren, ReactNode, useEffect } from 'react';
+import React, {
+ PropsWithChildren,
+ ReactNode,
+ Suspense,
+ useEffect,
+} from 'react';
import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { ErrorBoundary } from './ErrorBoundary';
-import { ExtensionSuspense } from './ExtensionSuspense';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
import { AppNode } from '../apis';
@@ -60,12 +64,12 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
};
return (
-
+ {children}
-
+
);
}
diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts
index 7056a59271..a7d947599f 100644
--- a/packages/frontend-plugin-api/src/components/index.ts
+++ b/packages/frontend-plugin-api/src/components/index.ts
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+export { coreComponentsRefs, type ComponentRef } from './ComponentRef';
+
export {
ExtensionBoundary,
type ExtensionBoundaryProps,
diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx
new file mode 100644
index 0000000000..a20faa0b3a
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2023 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { lazy } from 'react';
+import {
+ AnyExtensionInputMap,
+ ExtensionInputValues,
+ coreExtensionData,
+ createExtension,
+} from '../wiring';
+import { Expand } from '../types';
+import { PortableSchema } from '../schema';
+import { ExtensionBoundary, ComponentRef } from '../components';
+
+/** @public */
+export function createComponentExtension<
+ TRef extends ComponentRef,
+ TConfig extends {},
+ TInputs extends AnyExtensionInputMap,
+>(options: {
+ ref: TRef;
+ disabled?: boolean;
+ inputs?: TInputs;
+ configSchema?: PortableSchema;
+ component:
+ | {
+ lazy: (values: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => Promise;
+ }
+ | {
+ sync: (values: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => TRef['T'];
+ };
+}) {
+ const id = options.ref.id;
+ return createExtension({
+ id,
+ attachTo: { id: 'core', input: 'components' },
+ inputs: options.inputs,
+ disabled: options.disabled,
+ configSchema: options.configSchema,
+ output: {
+ component: coreExtensionData.component,
+ },
+ factory({ config, inputs, node }) {
+ let ExtensionComponent: TRef['T'];
+
+ if ('sync' in options.component) {
+ ExtensionComponent = options.component.sync({ config, inputs });
+ } else {
+ const loader = options.component.lazy({ config, inputs });
+ ExtensionComponent = lazy(() =>
+ loader.then(component => ({ default: component })),
+ );
+ }
+
+ return {
+ component: {
+ ref: options.ref,
+ impl: props => (
+
+
+
+ ),
+ },
+ };
+ },
+ });
+}
diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts
index d696774272..a3be81f2a3 100644
--- a/packages/frontend-plugin-api/src/extensions/index.ts
+++ b/packages/frontend-plugin-api/src/extensions/index.ts
@@ -19,3 +19,4 @@ export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
export { createSignInPageExtension } from './createSignInPageExtension';
export { createThemeExtension } from './createThemeExtension';
+export { createComponentExtension } from './createComponentExtension';
diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts
index 10f89b81b5..a38a7991fb 100644
--- a/packages/frontend-plugin-api/src/index.ts
+++ b/packages/frontend-plugin-api/src/index.ts
@@ -29,3 +29,10 @@ export * from './routing';
export * from './schema';
export * from './apis/system';
export * from './wiring';
+
+export type {
+ CoreProgressComponent,
+ CoreBootErrorPageComponent,
+ CoreNotFoundErrorPageComponent,
+ CoreErrorBoundaryFallbackComponent,
+} from './types';
diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts
index 8b6f02a942..f3f68c362c 100644
--- a/packages/frontend-plugin-api/src/types.ts
+++ b/packages/frontend-plugin-api/src/types.ts
@@ -14,9 +14,37 @@
* limitations under the License.
*/
+import { BackstagePlugin } from '@backstage/core-plugin-api';
+import { ComponentType, PropsWithChildren } from 'react';
+
// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types?
/**
* Utility type to expand type aliases into their equivalent type.
* @ignore
*/
export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never;
+
+/** @public */
+export type CoreProgressComponent = ComponentType>;
+
+/** @public */
+export type CoreBootErrorPageComponent = ComponentType<
+ PropsWithChildren<{
+ step: 'load-config' | 'load-chunk';
+ error: Error;
+ }>
+>;
+
+/** @public */
+export type CoreNotFoundErrorPageComponent = ComponentType<
+ PropsWithChildren<{}>
+>;
+
+/** @public */
+export type CoreErrorBoundaryFallbackComponent = ComponentType<
+ PropsWithChildren<{
+ plugin?: BackstagePlugin;
+ error: Error;
+ resetError: () => void;
+ }>
+>;
diff --git a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts
index 9ffdb835ba..9bdb4b23eb 100644
--- a/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts
+++ b/packages/frontend-plugin-api/src/wiring/coreExtensionData.ts
@@ -14,14 +14,15 @@
* limitations under the License.
*/
-import { JSX } from 'react';
+import { ComponentType, JSX } from 'react';
import {
AnyApiFactory,
AppTheme,
IconComponent,
} from '@backstage/core-plugin-api';
-import { createExtensionDataRef } from './createExtensionDataRef';
import { RouteRef } from '../routing';
+import { ComponentRef } from '../components';
+import { createExtensionDataRef } from './createExtensionDataRef';
/** @public */
export type NavTarget = {
@@ -45,4 +46,8 @@ export const coreExtensionData = {
navTarget: createExtensionDataRef('core.nav.target'),
theme: createExtensionDataRef('core.theme'),
logoElements: createExtensionDataRef('core.logos'),
+ component: createExtensionDataRef<{
+ ref: ComponentRef>;
+ impl: ComponentType;
+ }>('component.ref'),
};