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. + + Backstage bowie + + + ); +} + +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'), };