diff --git a/.changeset/component-refs-app.md b/.changeset/component-refs-app.md new file mode 100644 index 0000000000..5a7b6723bf --- /dev/null +++ b/.changeset/component-refs-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Default implementations of core components are now provided by this package. diff --git a/.changeset/component-refs-breaking-app.md b/.changeset/component-refs-breaking-app.md new file mode 100644 index 0000000000..9ba6307f60 --- /dev/null +++ b/.changeset/component-refs-breaking-app.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-app': minor +--- + +**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead. + +If you were overriding the `componentsApi` implementation, you can now use the new `SwappableComponentsApi` instead. + +```ts +// old +appPlugin.getExtension('api:app/components').override(...) + +// new +appPlugin.getExtension('api:app/swappable-components').override(...) +``` diff --git a/.changeset/component-refs-breaking-frontend-plugin-api.md b/.changeset/component-refs-breaking-frontend-plugin-api.md new file mode 100644 index 0000000000..47c40865d3 --- /dev/null +++ b/.changeset/component-refs-breaking-frontend-plugin-api.md @@ -0,0 +1,96 @@ +--- +'@backstage/frontend-plugin-api': minor +--- + +**BREAKING**: The component system has been overhauled to use `SwappableComponent` instead of `ComponentRef`. Several APIs have been removed and replaced: + +- Removed: `createComponentRef`, `createComponentExtension`, `ComponentRef`, `ComponentsApi`, `componentsApiRef`, `useComponentRef`, `coreComponentRefs` +- Added: `createSwappableComponent`, `SwappableComponentBlueprint`, `SwappableComponentRef`, `SwappableComponentsApi`, `swappableComponentsApiRef` + +**BREAKING**: The default `componentRefs` and exported `Core*Props` have been removed and have replacement `SwappableComponents` and revised type names instead. + +- The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively. +- The `progress` component and `CoreProgressProps` type have been replaced with `Progress` swappable component and `ProgressProps` respectively. +- The `notFoundErrorPage` component and `CoreNotFoundErrorPageProps` type have been replaced with `NotFoundErrorPage` swappable component and `NotFoundErrorPageProps` respectively. + +**Migration for creating swappable components:** + +```tsx +// OLD: Using createComponentRef and createComponentExtension +import { + createComponentRef, + createComponentExtension, +} from '@backstage/frontend-plugin-api'; + +const myComponentRef = createComponentRef<{ title: string }>({ + id: 'my-plugin.my-component', +}); + +const myComponentExtension = createComponentExtension({ + ref: myComponentRef, + loader: { + lazy: () => import('./MyComponent').then(m => m.MyComponent), + }, +}); + +// NEW: Using createSwappableComponent and SwappableComponentBlueprint +import { + createSwappableComponent, + SwappableComponentBlueprint, +} from '@backstage/frontend-plugin-api'; + +const MySwappableComponent = createSwappableComponent({ + id: 'my-plugin.my-component', + loader: () => import('./MyComponent').then(m => m.MyComponent), +}); + +const myComponentExtension = SwappableComponentBlueprint.make({ + name: 'my-component', + params: { + component: MySwappableComponent, + loader: () => import('./MyComponent').then(m => m.MyComponent), + }, +}); +``` + +**Migration for using components:** + +```tsx +// OLD: Using ComponentsApi and useComponentRef +import { + useComponentRef, + componentsApiRef, + useApi, + coreComponentRefs, +} from '@backstage/frontend-plugin-api'; + +const MyComponent = useComponentRef(myComponentRef); +const ProgressComponent = useComponentRef(coreComponentRefs.progress); + + +// NEW: Direct component usage +import { Progress } from '@backstage/frontend-plugin-api'; + +// Use directly as React Component + + +``` + +**Migration for core component references:** + +```tsx +// OLD: Core component refs +import { coreComponentRefs } from '@backstage/frontend-plugin-api'; + +coreComponentRefs.progress +coreComponentRefs.notFoundErrorPage +coreComponentRefs.errorBoundaryFallback + +// NEW: Direct swappable component imports +import { Progress, NotFoundErrorPage, ErrorDisplay } from '@backstage/frontend-plugin-api'; + +// Use directly as React components + + + +``` diff --git a/.changeset/fuzzy-ducks-jump.md b/.changeset/fuzzy-ducks-jump.md new file mode 100644 index 0000000000..9aa2288598 --- /dev/null +++ b/.changeset/fuzzy-ducks-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': minor +--- + +**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead. Which means that the `componentsApi` is not longer backwards compatible with legacy plugins. diff --git a/.changeset/strong-dogs-raise.md b/.changeset/strong-dogs-raise.md new file mode 100644 index 0000000000..76220474a9 --- /dev/null +++ b/.changeset/strong-dogs-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Added a default implementation of the `SwappableComponentsApi` and removing the legacy `ComponentsApi` implementation diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 86c9be9b98..5eba84e61d 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -467,6 +467,7 @@ Superfences superset supertype SVGs +swappable talkdesk Talkdesk Tanzu diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index e1e903eadd..330da0b97e 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -17,10 +17,6 @@ These are the [extension blueprints](../architecture/23-extension-blueprints.md) An API extension is used to add or override [Utility API factories](../utility-apis/01-index.md) in the app. They are commonly used by plugins for both internal and shared APIs. There are also many built-in Api extensions provided by the framework that you are able to override. -### Component - [Reference](../../reference/frontend-plugin-api.createcomponentextension.md) - -Components extensions are used to override the component associated with a component reference throughout the app. This uses an extension creator function rather than a blueprint, but will likely be migrated to a blueprint in the future. - ### NavItem - [Reference](../../reference/frontend-plugin-api.navitemblueprint.md) Navigation item extensions are used to provide menu items that link to different parts of the app. By default nav items are attached to the app nav extension, which by default is rendered as the left sidebar in the app. @@ -33,6 +29,10 @@ Page extensions provide content for a particular route in the app. By default pa Sign-in page extension have a single purpose - to implement a custom sign-in page. They are always attached to the app root extension and are rendered before the rest of the app until the user is signed in. +### SwappableComponent - [Reference](../../reference/frontend-plugin-api.swappablecomponentblueprint.md) + +Swappable Components are extensions that are used to replace the implementations of components in the app and plugins. + ### Theme - [Reference](../../reference/frontend-plugin-api.themeblueprint.md) Theme extensions provide custom themes for the app. They are always attached to the app extension and you can have any number of themes extensions installed in an app at once, letting the user choose which theme to use. diff --git a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx index a636b2b220..7f690f90cb 100644 --- a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx +++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx @@ -15,14 +15,14 @@ */ import { - createComponentExtension, - coreComponentRefs, + SwappableComponentBlueprint, + NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; import Box from '@material-ui/core/Box'; import Typography from '@material-ui/core/Typography'; import { Button } from '@backstage/core-components'; -export function CustomNotFoundErrorPage() { +function CustomNotFoundErrorPage() { return ( CustomNotFoundErrorPage }, + params: define => + define({ + component: NotFoundErrorPage, + loader: () => CustomNotFoundErrorPage, + }), }); diff --git a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx index 0a5dd68aa3..6abaa433aa 100644 --- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx @@ -24,11 +24,12 @@ import { createFrontendPlugin as createNewPlugin, FrontendPlugin as NewFrontendPlugin, appTreeApiRef, - componentsApiRef, - coreComponentRefs, iconsApiRef, useApi, routeResolutionApiRef, + ErrorDisplay, + NotFoundErrorPage, + Progress, } from '@backstage/frontend-plugin-api'; import { AppComponents, @@ -92,7 +93,6 @@ function toNewPlugin(plugin: LegacyBackstagePlugin): NewFrontendPlugin { // Recreates the old AppContext APIs using the various new APIs that replaced it function LegacyAppContextProvider(props: { children: ReactNode }) { const appTreeApi = useApi(appTreeApiRef); - const componentsApi = useApi(componentsApiRef); const iconsApi = useApi(iconsApiRef); const appContext = useMemo(() => { @@ -100,15 +100,9 @@ function LegacyAppContextProvider(props: { children: ReactNode }) { let gatheredPlugins: LegacyBackstagePlugin[] | undefined = undefined; - const ErrorBoundaryFallback = componentsApi.getComponent( - coreComponentRefs.errorBoundaryFallback, - ); const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] = ({ plugin, ...rest }) => ( - + ); return { @@ -141,15 +135,13 @@ function LegacyAppContextProvider(props: { children: ReactNode }) { getComponents(): AppComponents { return { - NotFoundErrorPage: componentsApi.getComponent( - coreComponentRefs.notFoundErrorPage, - ), + NotFoundErrorPage: NotFoundErrorPage, BootErrorPage() { throw new Error( 'The BootErrorPage app component should not be accessed by plugins', ); }, - Progress: componentsApi.getComponent(coreComponentRefs.progress), + Progress: Progress, Router() { throw new Error( 'The Router app component should not be accessed by plugins', @@ -159,7 +151,7 @@ function LegacyAppContextProvider(props: { children: ReactNode }) { }; }, }; - }, [appTreeApi, componentsApi, iconsApi]); + }, [appTreeApi, iconsApi]); return ( diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index 925d30306c..6464032673 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -22,11 +22,11 @@ import { } from '@backstage/core-plugin-api'; import { AnyRouteRefParams, - ComponentRef, - ComponentsApi, - CoreErrorBoundaryFallbackProps, - CoreNotFoundErrorPageProps, - CoreProgressProps, + SwappableComponentRef, + SwappableComponentsApi, + ErrorDisplayProps, + NotFoundErrorPageProps, + ProgressProps, ExternalRouteRef, IconComponent, IconsApi, @@ -34,10 +34,12 @@ import { RouteRef, RouteResolutionApi, SubRouteRef, - componentsApiRef, - coreComponentRefs, + swappableComponentsApiRef, iconsApiRef, routeResolutionApiRef, + Progress, + NotFoundErrorPage, + ErrorDisplay, } from '@backstage/frontend-plugin-api'; import { ComponentType, useMemo } from 'react'; import { ReactNode } from 'react'; @@ -49,14 +51,14 @@ import { useVersionedContext } from '@backstage/version-bridge'; import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef'; import { convertLegacyRouteRef } from '../convertLegacyRouteRef'; -class CompatComponentsApi implements ComponentsApi { - readonly #Progress: ComponentType; - readonly #NotFoundErrorPage: ComponentType; - readonly #ErrorBoundaryFallback: ComponentType; +class CompatComponentsApi implements SwappableComponentsApi { + readonly #Progress: ComponentType; + readonly #NotFoundErrorPage: ComponentType; + readonly #ErrorBoundaryFallback: ComponentType; constructor(app: AppContext) { const components = app.getComponents(); - const ErrorBoundaryFallback = (props: CoreErrorBoundaryFallbackProps) => ( + const ErrorBoundaryFallback = (props: ErrorDisplayProps) => ( (ref: ComponentRef): ComponentType { + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: SwappableComponentRef, + ): (props: TInnerComponentProps) => JSX.Element | null { switch (ref.id) { - case coreComponentRefs.progress.id: - return this.#Progress as ComponentType; - case coreComponentRefs.notFoundErrorPage.id: - return this.#NotFoundErrorPage as ComponentType; - case coreComponentRefs.errorBoundaryFallback.id: - return this.#ErrorBoundaryFallback as ComponentType; + case Progress.ref.id: + return this.#Progress as (props: object) => JSX.Element | null; + case NotFoundErrorPage.ref.id: + return this.#NotFoundErrorPage as (props: object) => JSX.Element | null; + case ErrorDisplay.ref.id: + return this.#ErrorBoundaryFallback as ( + props: object, + ) => JSX.Element | null; default: throw new Error( `No backwards compatible component is available for ref '${ref.id}'`, @@ -119,7 +128,7 @@ class CompatRouteResolutionApi implements RouteResolutionApi { } class ForwardsCompatApis implements ApiHolder { - readonly #componentsApi: ComponentsApi; + readonly #componentsApi: SwappableComponentsApi; readonly #iconsApi: IconsApi; readonly #routeResolutionApi: RouteResolutionApi; @@ -130,7 +139,7 @@ class ForwardsCompatApis implements ApiHolder { } get(ref: ApiRef): T | undefined { - if (ref.id === componentsApiRef.id) { + if (ref.id === swappableComponentsApiRef.id) { return this.#componentsApi as T; } else if (ref.id === iconsApiRef.id) { return this.#iconsApi as T; diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index f1418e545f..fe9dab9910 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -15,14 +15,16 @@ */ import { - componentsApiRef, - coreComponentRefs, + swappableComponentsApiRef, coreExtensionData, createExtension, iconsApiRef, useRouteRef as useNewRouteRef, createRouteRef as createNewRouteRef, useApi, + NotFoundErrorPage, + ErrorDisplay, + Progress, } from '@backstage/frontend-plugin-api'; import { createExtensionTester, @@ -97,13 +99,19 @@ describe('BackwardsCompatProvider', () => { describe('ForwardsCompatProvider', () => { it('should convert the app context', async () => { + const defaultComponentRefs = { + progress: Progress.ref, + notFoundErrorPage: NotFoundErrorPage.ref, + errorDisplay: ErrorDisplay.ref, + }; + function Component() { - const components = useApi(componentsApiRef); + const components = useApi(swappableComponentsApiRef); const icons = useApi(iconsApiRef); return (
components:{' '} - {Object.entries(coreComponentRefs) + {Object.entries(defaultComponentRefs) .map( ([name, ref]) => `${name}=${Boolean(components.getComponent(ref))}`, @@ -118,7 +126,7 @@ describe('ForwardsCompatProvider', () => { await renderInOldTestApp(compatWrapper()); expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(` - "components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true + "components: progress=true, notFoundErrorPage=true, errorDisplay=true icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning, star, unstarred" `); }); diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx index f6c4e163ad..650ea9cd58 100644 --- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -16,10 +16,9 @@ import { ComponentType } from 'react'; import { + SwappableComponentBlueprint, ApiBlueprint, - coreComponentRefs, - CoreErrorBoundaryFallbackProps, - createComponentExtension, + ErrorDisplayProps, createExtension, createFrontendModule, ExtensionDefinition, @@ -28,6 +27,9 @@ import { RouterBlueprint, SignInPageBlueprint, ThemeBlueprint, + ErrorDisplay as SwappableErrorDisplay, + NotFoundErrorPage as SwappableNotFoundErrorPage, + Progress as SwappableProgress, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -154,36 +156,45 @@ export function convertLegacyAppOptions( } if (Progress) { extensions.push( - createComponentExtension({ - ref: coreComponentRefs.progress, - loader: { sync: () => componentCompatWrapper(Progress) }, + SwappableComponentBlueprint.make({ + params: define => + define({ + component: SwappableProgress, + loader: () => componentCompatWrapper(Progress), + }), }), ); } + if (NotFoundErrorPage) { extensions.push( - createComponentExtension({ - ref: coreComponentRefs.notFoundErrorPage, - loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) }, + SwappableComponentBlueprint.make({ + params: define => + define({ + component: SwappableNotFoundErrorPage, + loader: () => componentCompatWrapper(NotFoundErrorPage), + }), }), ); } + if (ErrorBoundaryFallback) { - const WrappedErrorBoundaryFallback = ( - props: CoreErrorBoundaryFallbackProps, - ) => + const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) => compatWrapper( , ); + extensions.push( - createComponentExtension({ - ref: coreComponentRefs.errorBoundaryFallback, - loader: { - sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback), - }, + SwappableComponentBlueprint.make({ + params: define => + define({ + component: SwappableErrorDisplay, + loader: () => + componentCompatWrapper(WrappedErrorBoundaryFallback), + }), }), ); } diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index ed71d61fab..ff891a4b24 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -45,6 +45,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-app": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx deleted file mode 100644 index 7d06d55a92..0000000000 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 { createComponentRef } from '@backstage/frontend-plugin-api'; -import { DefaultComponentsApi } from './DefaultComponentsApi'; -import { render, screen } from '@testing-library/react'; - -const testRefA = createComponentRef({ id: 'test.a' }); -const testRefB1 = createComponentRef({ id: 'test.b' }); -const testRefB2 = createComponentRef({ id: 'test.b' }); - -describe('DefaultComponentsApi', () => { - it('should provide components', () => { - const api = DefaultComponentsApi.fromComponents([ - { - ref: testRefA, - impl: () =>
test.a
, - }, - ]); - - const ComponentA = api.getComponent(testRefA); - render(); - - expect(screen.getByText('test.a')).toBeInTheDocument(); - }); - - it('should key extension refs by ID', () => { - const api = DefaultComponentsApi.fromComponents([ - { - ref: testRefB1, - impl: () =>
test.b
, - }, - ]); - - const ComponentB1 = api.getComponent(testRefB1); - const ComponentB2 = api.getComponent(testRefB2); - - expect(ComponentB1).toBe(ComponentB2); - - render(); - - expect(screen.getByText('test.b')).toBeInTheDocument(); - }); -}); diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts deleted file mode 100644 index 317233d797..0000000000 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 { ComponentType } from 'react'; -import { - ComponentRef, - ComponentsApi, - createComponentExtension, -} from '@backstage/frontend-plugin-api'; - -/** - * Implementation for the {@linkComponentApi} - * - * @internal - */ -export class DefaultComponentsApi implements ComponentsApi { - #components: Map>; - - static fromComponents( - components: Array, - ) { - return new DefaultComponentsApi( - new Map(components.map(entry => [entry.ref.id, entry.impl])), - ); - } - - constructor(components: Map) { - this.#components = components; - } - - getComponent(ref: ComponentRef): ComponentType { - const impl = this.#components.get(ref.id); - if (!impl) { - throw new Error(`No implementation found for component ref ${ref}`); - } - return impl; - } -} diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx new file mode 100644 index 0000000000..823d48e21c --- /dev/null +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -0,0 +1,264 @@ +/* + * 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 { + ApiBlueprint, + createExtensionInput, + createSwappableComponent, + SwappableComponentBlueprint, + swappableComponentsApiRef, +} from '@backstage/frontend-plugin-api'; +import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi'; +import { render, screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; + +const { ref: testRefA } = createSwappableComponent({ id: 'test.a' }); +const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' }); +const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' }); + +describe('DefaultSwappableComponentsApi', () => { + it('should provide components', async () => { + const api = DefaultSwappableComponentsApi.fromComponents([ + { + ref: testRefA, + loader: () => () =>
test.a
, + }, + ]); + + const ComponentA = api.getComponent(testRefA); + + render(); + + await expect(screen.findByText('test.a')).resolves.toBeInTheDocument(); + }); + + it('should key extension refs by ID', async () => { + const mockLoader = jest.fn(() =>
test.b
); + const api = DefaultSwappableComponentsApi.fromComponents([ + { + ref: testRefB1, + loader: () => mockLoader, + }, + ]); + + const ComponentB2 = api.getComponent(testRefB2); + + render(); + + await expect(screen.findByText('test.b')).resolves.toBeInTheDocument(); + }); + + describe('integration tests', () => { + const api = ApiBlueprint.makeWithOverrides({ + name: 'swappable-components', + inputs: { + components: createExtensionInput([ + SwappableComponentBlueprint.dataRefs.component, + ]), + }, + factory: (originalFactory, { inputs }) => { + return originalFactory(defineParams => + defineParams({ + api: swappableComponentsApiRef, + deps: {}, + factory: () => + DefaultSwappableComponentsApi.fromComponents( + inputs.components.map(i => + i.get(SwappableComponentBlueprint.dataRefs.component), + ), + ), + }), + ); + }, + }); + + describe('no api provided', () => { + it('should render the fallback if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + }); + + renderInTestApp(, {}); + + await expect( + screen.findByTestId('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should render the default compnoent if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => () =>
test.mock
, + }); + + renderInTestApp(, {}); + + await expect( + screen.findByText('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should render async loader component if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: async () => () =>
test.mock
, + }); + + renderInTestApp(, {}); + + await expect( + screen.findByText('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should transform props correctly', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => (props: { inner: string }) => +
inner: {props.inner}
, + transformProps: (props: { external: string }) => ({ + inner: props.external, + }), + }); + + renderInTestApp(, {}); + + await expect( + screen.findByText('inner: test'), + ).resolves.toBeInTheDocument(); + }); + }); + + describe('with overrides', () => { + it('should render the fallback if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + }); + + renderInTestApp(, { + extensions: [api], + }); + + await expect( + screen.findByTestId('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should render the default compnoent if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => () =>
test.mock
, + }); + + renderInTestApp(, { + extensions: [api], + }); + + await expect( + screen.findByText('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should render async loader component if no other component is provided', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: async () => () =>
test.mock
, + }); + + renderInTestApp(, { + extensions: [api], + }); + + await expect( + screen.findByText('test.mock'), + ).resolves.toBeInTheDocument(); + }); + + it('should render the component provided by the blueprint', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => () =>
test.mock
, + }); + + const override = SwappableComponentBlueprint.make({ + params: define => + define({ + component: MockComponent, + loader: () => () =>
Overridden!
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('Overridden!'), + ).resolves.toBeInTheDocument(); + }); + + it('should render the async component provided by the blueprint', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => () =>
test.mock
, + }); + + const override = SwappableComponentBlueprint.make({ + params: define => + define({ + component: MockComponent, + loader: async () => () =>
Overridden!
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('Overridden!'), + ).resolves.toBeInTheDocument(); + }); + + it('should transform props correctly', async () => { + const MockComponent = createSwappableComponent({ + id: 'test.mock', + loader: () => (props: { inner: string }) => +
inner: {props.inner}
, + transformProps: (props: { external: string }) => ({ + inner: props.external, + }), + }); + + const override = SwappableComponentBlueprint.make({ + params: define => + define({ + component: MockComponent, + loader: () => props =>
overridden: {props.inner}
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('overridden: test'), + ).resolves.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx new file mode 100644 index 0000000000..593fff24ff --- /dev/null +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + SwappableComponentRef, + SwappableComponentsApi, + SwappableComponentBlueprint, +} from '@backstage/frontend-plugin-api'; +import { OpaqueSwappableComponentRef } from '@internal/frontend'; + +import { lazy } from 'react'; + +/** + * Implementation for the {@link SwappableComponentsApi} + * + * @internal + */ +export class DefaultSwappableComponentsApi implements SwappableComponentsApi { + #components: Map JSX.Element | null) | undefined>; + + static fromComponents( + components: Array, + ) { + return new DefaultSwappableComponentsApi( + new Map( + components.map(entry => { + return [ + entry.ref.id, + entry.loader + ? lazy(async () => ({ + default: await entry.loader!(), + })) + : undefined, + ]; + }), + ), + ); + } + + constructor(components: Map) { + this.#components = components; + } + + getComponent( + ref: SwappableComponentRef, + ): (props: object) => JSX.Element | null { + const OverrideComponent = this.#components.get(ref.id); + const { defaultComponent: DefaultComponent, transformProps } = + OpaqueSwappableComponentRef.toInternal(ref); + + return (props: object) => { + const innerProps = transformProps?.(props) ?? props; + + if (OverrideComponent) { + return ; + } + + return ; + }; + } +} diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts similarity index 88% rename from packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts index 18604f15de..20f90eb32d 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/index.ts +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { DefaultComponentsApi } from './DefaultComponentsApi'; +export { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi'; diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 89ac0a5b39..35a69654e9 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -366,13 +366,13 @@ describe('createApp', () => { ] - + components [ - - - + + + ] - + diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts similarity index 53% rename from packages/frontend-plugin-api/src/components/createComponentRef.test.tsx rename to packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts index 9a84fe08fa..2a5da3fa73 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2025 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. @@ -14,12 +14,17 @@ * limitations under the License. */ -import { createComponentRef } from './createComponentRef'; +import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; -describe('createComponentRef', () => { - it('can be created and read', () => { - const ref = createComponentRef({ id: 'foo' }); - expect(ref.id).toBe('foo'); - expect(String(ref)).toBe('ComponentRef{id=foo}'); - }); +export const OpaqueSwappableComponentRef = OpaqueType.create<{ + public: SwappableComponentRef; + versions: { + readonly version: 'v1'; + readonly transformProps?: (props: object) => object; + readonly defaultComponent: (props: object) => JSX.Element | null; + }; +}>({ + versions: ['v1'], + type: '@backstage/SwappableComponentRef', }); diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index 6a7cf35e27..b61294cb1f 100644 --- a/packages/frontend-internal/src/wiring/index.ts +++ b/packages/frontend-internal/src/wiring/index.ts @@ -15,5 +15,6 @@ */ export { createExtensionDataContainer } from './createExtensionDataContainer'; +export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef'; export { OpaqueExtensionDefinition } from './InternalExtensionDefinition'; export { OpaqueFrontendPlugin } from './InternalFrontendPlugin'; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index ae32fd0f50..5e841aeeee 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -350,21 +350,6 @@ export { bitbucketAuthApiRef }; export { bitbucketServerAuthApiRef }; -// @public (undocumented) -export type ComponentRef = { - id: string; - T: T; -}; - -// @public -export interface ComponentsApi { - // (undocumented) - getComponent(ref: ComponentRef): ComponentType; -} - -// @public -export const componentsApiRef: ApiRef; - export { ConfigApi }; export { configApiRef }; @@ -389,20 +374,6 @@ export interface ConfigurableExtensionDataRef< >; } -// @public (undocumented) -export const coreComponentRefs: { - progress: ComponentRef; - notFoundErrorPage: ComponentRef; - errorBoundaryFallback: ComponentRef; -}; - -// @public (undocumented) -export type CoreErrorBoundaryFallbackProps = { - plugin?: FrontendPlugin; - error: Error; - resetError: () => void; -}; - // @public (undocumented) export const coreExtensionData: { reactElement: ConfigurableExtensionDataRef< @@ -418,73 +389,10 @@ export const coreExtensionData: { >; }; -// @public (undocumented) -export type CoreNotFoundErrorPageProps = { - children?: ReactNode; -}; - -// @public (undocumented) -export type CoreProgressProps = {}; - export { createApiFactory }; export { createApiRef }; -// @public (undocumented) -export function createComponentExtension(options: { - ref: ComponentRef; - name?: string; - disabled?: boolean; - loader: - | { - lazy: () => Promise>; - } - | { - sync: () => ComponentType; - }; -}): ExtensionDefinition<{ - config: {}; - configInput: {}; - output: ExtensionDataRef< - { - ref: ComponentRef; - impl: ComponentType; - }, - 'core.component.component', - {} - >; - inputs: { - [x: string]: ExtensionInput< - ExtensionDataRef, - { - optional: boolean; - singleton: boolean; - } - >; - }; - params: never; - kind: 'component'; - name: string; -}>; - -// @public (undocumented) -export namespace createComponentExtension { - const // (undocumented) - componentDataRef: ConfigurableExtensionDataRef< - { - ref: ComponentRef; - impl: ComponentType; - }, - 'core.component.component', - {} - >; -} - -// @public (undocumented) -export function createComponentRef(options: { - id: string; -}): ComponentRef; - // @public (undocumented) export function createExtension< UOutput extends ExtensionDataRef, @@ -849,6 +757,31 @@ export function createSubRouteRef< parent: RouteRef; }): MakeSubRouteRef, ParentParams>; +// @public +export function createSwappableComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +>( + options: CreateSwappableComponentOptions< + TInnerComponentProps, + TExternalComponentProps + >, +): ((props: TExternalComponentProps) => JSX.Element | null) & { + ref: SwappableComponentRef; +}; + +// @public +export type CreateSwappableComponentOptions< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + loader?: + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); + transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; +}; + export { createTranslationMessages }; export { createTranslationRef }; @@ -899,6 +832,20 @@ export { ErrorApiErrorContext }; export { errorApiRef }; +// @public (undocumented) +export const ErrorDisplay: (( + props: ErrorDisplayProps, +) => JSX.Element | null) & { + ref: SwappableComponentRef; +}; + +// @public (undocumented) +export type ErrorDisplayProps = { + plugin?: FrontendPlugin; + error: Error; + resetError: () => void; +}; + // @public (undocumented) export interface Extension { // (undocumented) @@ -1554,6 +1501,18 @@ export const NavItemBlueprint: ExtensionBlueprint<{ }; }>; +// @public (undocumented) +export const NotFoundErrorPage: (( + props: NotFoundErrorPageProps, +) => JSX.Element | null) & { + ref: SwappableComponentRef; +}; + +// @public (undocumented) +export type NotFoundErrorPageProps = { + children?: ReactNode; +}; + export { OAuthApi }; export { OAuthRequestApi }; @@ -1638,6 +1597,14 @@ export { ProfileInfo }; export { ProfileInfoApi }; +// @public (undocumented) +export const Progress: ((props: ProgressProps) => JSX.Element | null) & { + ref: SwappableComponentRef; +}; + +// @public (undocumented) +export type ProgressProps = {}; + // @public export type ResolvedExtensionInput< TExtensionInput extends ExtensionInput, @@ -1830,6 +1797,90 @@ export interface SubRouteRef< readonly T: TParams; } +// @public +export const SwappableComponentBlueprint: ExtensionBlueprint<{ + kind: 'component'; + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }>; + output: ExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >; + }; +}>; + +// @public (undocumented) +export type SwappableComponentRef< + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/SwappableComponentRef'; +}; + +// @public +export interface SwappableComponentsApi { + // (undocumented) + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: SwappableComponentRef, + ): (props: TInnerComponentProps) => JSX.Element | null; +} + +// @public +export const swappableComponentsApiRef: ApiRef; + // @public export const ThemeBlueprint: ExtensionBlueprint<{ kind: 'theme'; @@ -1906,11 +1957,6 @@ export { useApiHolder }; // @public export function useAppNode(): AppNode | undefined; -// @public -export function useComponentRef( - ref: ComponentRef, -): ComponentType; - // @public export function useRouteRef( routeRef: diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts deleted file mode 100644 index c5e9d2c44f..0000000000 --- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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 { ComponentType } from 'react'; -import { createApiRef, useApi } 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): ComponentType; -} - -/** - * The `ApiRef` of {@link ComponentsApi}. - * - * @public - */ -export const componentsApiRef = createApiRef({ - id: 'core.components', -}); - -/** - * @public - * Returns the component associated with the given ref. - */ -export function useComponentRef( - ref: ComponentRef, -): ComponentType { - const componentsApi = useApi(componentsApiRef); - return componentsApi.getComponent(ref); -} diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts new file mode 100644 index 0000000000..ed2b20ce80 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -0,0 +1,41 @@ +/* + * 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 { SwappableComponentRef } from '../../components'; +import { createApiRef } from '@backstage/core-plugin-api'; + +/** + * API for looking up components based on component refs. + * + * @public + */ +export interface SwappableComponentsApi { + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: SwappableComponentRef, + ): (props: TInnerComponentProps) => JSX.Element | null; +} + +/** + * The `ApiRef` of {@link SwappableComponentsApi}. + * + * @public + */ +export const swappableComponentsApiRef = createApiRef({ + id: 'core.swappable-components', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 0da23fcacb..7533481d01 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -34,7 +34,7 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; -export * from './ComponentsApi'; +export * from './SwappableComponentsApi'; export * from './ConfigApi'; export * from './DiscoveryApi'; export * from './ErrorApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx new file mode 100644 index 0000000000..fae4bf7ebf --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright 2025 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 { renderInTestApp } from '@backstage/frontend-test-utils'; +import { createSwappableComponent } from '../components'; +import { SwappableComponentBlueprint } from './SwappableComponentBlueprint'; +import { PageBlueprint } from './PageBlueprint'; +import { waitFor, screen } from '@testing-library/react'; + +describe('SwappableComponentBlueprint', () => { + it('should allow defining a component override for a component ref', () => { + const Component = createSwappableComponent({ + id: 'test.component', + loader: () => (props: { hello: string }) =>
{props.hello}
, + }); + + const extension = SwappableComponentBlueprint.make({ + params: define => + define({ + component: Component, + loader: () => props => { + // @ts-expect-error + const t: number = props.hello; + + return
Override {props.hello}
; + }, + }), + }); + + expect(extension).toBeDefined(); + }); + + it('should render default component refs in the app', async () => { + const TestComponent = createSwappableComponent({ + id: 'test.component', + loader: () => (props: { hello: string }) =>
{props.hello}
, + }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + // todo(blam): there's a bug that this path cannot be `/`? + path: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + }); + + it('should render a component ref without a default implementation', async () => { + const TestComponent = createSwappableComponent({ + id: 'test.component', + }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + path: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => + expect(screen.getByTestId('test.component')).toBeInTheDocument(), + ); + }); + + it('should render a component ref with an async loader implementation', async () => { + const TestComponent = createSwappableComponent({ + id: 'test.component', + loader: async () => (props: { hello: string }) => +
{props.hello}
, + }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + // todo(blam): there's a bug that this path cannot be `/`? + path: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + }); + + it('should render a component ref with an async loader implementation and prop transform', async () => { + const TestComponent = createSwappableComponent({ + id: 'test.component', + loader: async () => (props: { hello: string }) => +
{props.hello}
, + transformProps: ({ hello }) => ({ hello: `tr ${hello}` }), + }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + // todo(blam): there's a bug that this path cannot be `/`? + path: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => + expect(screen.getByText('tr test!')).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts new file mode 100644 index 0000000000..b72d14d87f --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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 { SwappableComponentRef } from '../components'; +import { + createExtensionBlueprint, + createExtensionBlueprintParams, + createExtensionDataRef, +} from '../wiring'; + +export const componentDataRef = createExtensionDataRef<{ + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); +}>().with({ id: 'core.swappableComponent' }); + +/** + * Blueprint for creating swappable components from a SwappableComponentRef and a loader + * + * @public + */ +export const SwappableComponentBlueprint = createExtensionBlueprint({ + kind: 'component', + attachTo: { id: 'api:app/swappable-components', input: 'components' }, + output: [componentDataRef], + dataRefs: { + component: componentDataRef, + }, + defineParams>(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }) { + return createExtensionBlueprintParams(params); + }, + factory: params => [ + componentDataRef({ + ref: params.component.ref, + loader: params.loader, + }), + ], +}); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index 0060571ca2..c8699b4232 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -33,3 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint'; export { SignInPageBlueprint } from './SignInPageBlueprint'; export { ThemeBlueprint } from './ThemeBlueprint'; export { TranslationBlueprint } from './TranslationBlueprint'; +export { SwappableComponentBlueprint } from './SwappableComponentBlueprint'; diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx new file mode 100644 index 0000000000..e9a4220142 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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 { + ErrorDisplayProps, + NotFoundErrorPageProps, + ProgressProps, +} from '../types'; +import { createSwappableComponent } from './createSwappableComponent'; + +/** + * @public + */ +export const Progress = createSwappableComponent({ + id: 'core.components.progress', +}); + +/** + * @public + */ +export const NotFoundErrorPage = + createSwappableComponent({ + id: 'core.components.notFoundErrorPage', + }); + +/** + * @public + */ +export const ErrorDisplay = createSwappableComponent({ + id: 'core.components.errorDisplay', + loader: () => props => +
{props.error.message}
, +}); diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx index 8842ff55cc..4cde84ee93 100644 --- a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ -import { Component, ComponentType, PropsWithChildren } from 'react'; +import { Component, PropsWithChildren } from 'react'; import { FrontendPlugin } from '../wiring'; -import { CoreErrorBoundaryFallbackProps } from '../types'; +import { ErrorDisplay } from './DefaultSwappableComponents'; type ErrorBoundaryProps = PropsWithChildren<{ plugin?: FrontendPlugin; - Fallback: ComponentType; }>; type ErrorBoundaryState = { error?: Error }; @@ -41,13 +40,15 @@ export class ErrorBoundary extends Component< render() { const { error } = this.state; - const { plugin, children, Fallback } = this.props; + const { plugin, children } = this.props; if (error) { return ( - ); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index f653fa1742..848ea64a2c 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -25,10 +25,10 @@ import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api'; import { ErrorBoundary } from './ErrorBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; -import { AppNode, useComponentRef } from '../apis'; -import { coreComponentRefs } from './coreComponentRefs'; +import { AppNode } from '../apis'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; +import { Progress } from './DefaultSwappableComponents'; type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; @@ -66,8 +66,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { ); const plugin = node.spec.plugin; - const Progress = useComponentRef(coreComponentRefs.progress); - const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback); // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { @@ -78,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { return ( }> - + {children} diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts deleted file mode 100644 index 64412b8710..0000000000 --- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { - CoreErrorBoundaryFallbackProps, - CoreNotFoundErrorPageProps, - CoreProgressProps, -} from '../types'; -import { createComponentRef } from './createComponentRef'; - -const coreProgressComponentRef = createComponentRef({ - id: 'core.components.progress', -}); - -const coreNotFoundErrorPageComponentRef = - createComponentRef({ - id: 'core.components.notFoundErrorPage', - }); - -const coreErrorBoundaryFallbackComponentRef = - createComponentRef({ - id: 'core.components.errorBoundaryFallback', - }); - -/** @public */ -export const coreComponentRefs = { - progress: coreProgressComponentRef, - notFoundErrorPage: coreNotFoundErrorPageComponentRef, - errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef, -}; diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx deleted file mode 100644 index 88181dd079..0000000000 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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. - */ - -/** @public */ -export type ComponentRef = { - id: string; - T: T; -}; - -/** @public */ -export function createComponentRef(options: { - id: string; -}): ComponentRef { - const { id } = options; - return { - id, - toString() { - return `ComponentRef{id=${id}}`; - }, - } as ComponentRef; -} diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx new file mode 100644 index 0000000000..2a73a4da42 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx @@ -0,0 +1,163 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { createSwappableComponent } from './createSwappableComponent'; + +describe('createSwappableComponent', () => { + it('can be created and read', () => { + const { ref } = createSwappableComponent({ id: 'foo' }); + expect(ref.id).toBe('foo'); + expect(String(ref)).toBe('SwappableComponentRef{id=foo}'); + }); + + it('should allow defining a default component implementation', () => { + const Test = () =>
test
; + + createSwappableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + loader: + () => + ({ foo }) => + , + }); + + createSwappableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + loader: + async () => + ({ foo }) => + , + }); + + createSwappableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + }); + + expect(Test).toBeDefined(); + }); + + it('should allow transformings props', () => { + createSwappableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + transformProps: props => ({ foo: props.bar }), + }); + + createSwappableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + // @ts-expect-error - this should be an error as foo is not a string + transformProps: props => ({ foo: 1 }), + }); + + expect(true).toBe(true); + }); + + describe('sync', () => { + it('should create a component from a ref for sync component', async () => { + const Component = createSwappableComponent({ + id: 'random', + loader: () => (props: { name: string }) => { + return
{props.name}
; + }, + transformProps: (props: { id: string }) => ({ + name: props.id, + }), + }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'test', + ); + }); + + it('should render a fallback when theres no default implementation provided', async () => { + const Component = createSwappableComponent({ + id: 'random', + }); + + render(); + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); + }); + + it('should map props from external to internal', async () => { + const Component = createSwappableComponent({ + id: 'random', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + loader: () => props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'TEST', + ); + }); + }); + + describe('async', () => { + it('should create a component from a ref for async component', async () => { + const Component = createSwappableComponent({ + id: 'random', + loader: async () => (props: { name: string }) => { + return
{props.name}
; + }, + }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toBeInTheDocument(); + }); + + it('should render a fallback when theres no default implementation provided', async () => { + const Component = createSwappableComponent({ + id: 'random', + }); + + render(); + + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); + }); + + it('should map props from external to internal', async () => { + const Component = createSwappableComponent({ + id: 'random', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + loader: async () => props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'TEST', + ); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx new file mode 100644 index 0000000000..b977a46839 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -0,0 +1,110 @@ +/* + * 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 { OpaqueSwappableComponentRef } from '@internal/frontend'; +import { swappableComponentsApiRef, useApi } from '../apis'; +import { lazy } from 'react'; + +/** @public */ +export type SwappableComponentRef< + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/SwappableComponentRef'; +}; + +/** + * Options for creating an SwappableComponent. + * + * @public + */ +export type CreateSwappableComponentOptions< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + loader?: + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); + transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; +}; + +const useComponentRefApi = () => { + try { + return useApi(swappableComponentsApiRef); + } catch (e) { + return undefined; + } +}; + +/** + * Creates a SwappableComponent that can be used to render the component, optionally overridden by the app. + * + * @public + */ +export function createSwappableComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +>( + options: CreateSwappableComponentOptions< + TInnerComponentProps, + TExternalComponentProps + >, +): ((props: TExternalComponentProps) => JSX.Element | null) & { + ref: SwappableComponentRef; +} { + const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( +
+ ); + + const ref = OpaqueSwappableComponentRef.createInstance('v1', { + id: options.id, + TProps: null as unknown as TInnerComponentProps, + TExternalProps: null as unknown as TExternalComponentProps, + toString() { + return `SwappableComponentRef{id=${options.id}}`; + }, + defaultComponent: lazy(async () => { + const Component = (await options.loader?.()) ?? FallbackComponent; + return { default: Component }; + }) as (typeof OpaqueSwappableComponentRef.TInternal)['defaultComponent'], + transformProps: + options.transformProps as (typeof OpaqueSwappableComponentRef.TInternal)['transformProps'], + }); + + const ComponentRefImpl = (props: TExternalComponentProps) => { + const api = useComponentRefApi(); + + if (!api) { + const internalRef = OpaqueSwappableComponentRef.toInternal(ref); + const Component = internalRef.defaultComponent; + const innerProps = internalRef.transformProps?.(props) ?? props; + return ; + } + + const Component = api.getComponent(ref); + return ; + }; + + Object.assign(ComponentRefImpl, { ref }); + + return ComponentRefImpl as { + ref: SwappableComponentRef; + } & ((props: TExternalComponentProps) => JSX.Element | null); +} diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 31a53be24c..450224bdc4 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -18,6 +18,10 @@ export { ExtensionBoundary, type ExtensionBoundaryProps, } from './ExtensionBoundary'; -export { coreComponentRefs } from './coreComponentRefs'; -export { createComponentRef, type ComponentRef } from './createComponentRef'; +export { + createSwappableComponent, + type CreateSwappableComponentOptions, + type SwappableComponentRef, +} from './createSwappableComponent'; export { useAppNode } from './AppNodeProvider'; +export * from './DefaultSwappableComponents'; diff --git a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx b/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx deleted file mode 100644 index f561a73dc6..0000000000 --- a/packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 { lazy, ComponentType } from 'react'; -import { createExtension, createExtensionDataRef } from '../wiring'; -import { ComponentRef } from '../components'; - -/** @public */ -export function createComponentExtension(options: { - ref: ComponentRef; - name?: string; - disabled?: boolean; - loader: - | { - lazy: () => Promise>; - } - | { - sync: () => ComponentType; - }; -}) { - return createExtension({ - kind: 'component', - name: options.name ?? options.ref.id, - attachTo: { id: 'api:app/components', input: 'components' }, - disabled: options.disabled, - output: [createComponentExtension.componentDataRef], - factory() { - if ('sync' in options.loader) { - return [ - createComponentExtension.componentDataRef({ - ref: options.ref, - impl: options.loader.sync() as ComponentType, - }), - ]; - } - const lazyLoader = options.loader.lazy; - const ExtensionComponent = lazy(() => - lazyLoader().then(Component => ({ - default: Component, - })), - ) as unknown as ComponentType; - - return [ - createComponentExtension.componentDataRef({ - ref: options.ref, - impl: ExtensionComponent, - }), - ]; - }, - }); -} - -/** @public */ -export namespace createComponentExtension { - export const componentDataRef = createExtensionDataRef<{ - ref: ComponentRef; - impl: ComponentType; - }>().with({ id: 'core.component.component' }); -} diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts deleted file mode 100644 index f75c7474ac..0000000000 --- a/packages/frontend-plugin-api/src/extensions/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 { createComponentExtension } from './createComponentExtension'; diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index 957ca50bba..9117dc0ef9 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -24,7 +24,6 @@ export * from './analytics'; export * from './apis'; export * from './blueprints'; export * from './components'; -export * from './extensions'; export * from './icons'; export * from './routing'; export * from './schema'; @@ -33,7 +32,7 @@ export * from './translation'; export * from './wiring'; export type { - CoreProgressProps, - CoreNotFoundErrorPageProps, - CoreErrorBoundaryFallbackProps, + ProgressProps, + NotFoundErrorPageProps, + ErrorDisplayProps, } from './types'; diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts index 9088a9d772..a8ddf43cad 100644 --- a/packages/frontend-plugin-api/src/types.ts +++ b/packages/frontend-plugin-api/src/types.ts @@ -18,15 +18,15 @@ import { ReactNode } from 'react'; import { FrontendPlugin } from './wiring'; /** @public */ -export type CoreProgressProps = {}; +export type ProgressProps = {}; /** @public */ -export type CoreNotFoundErrorPageProps = { +export type NotFoundErrorPageProps = { children?: ReactNode; }; /** @public */ -export type CoreErrorBoundaryFallbackProps = { +export type ErrorDisplayProps = { plugin?: FrontendPlugin; error: Error; resetError: () => void; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index f94551755f..fd9fb3b9f5 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -214,7 +214,6 @@ type AnyParamsInput = * @public */ export interface ExtensionBlueprint< - // TParamsMapper extends (params: any) => object, T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters, > { dataRefs: T['dataRefs']; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index f6632550c2..423da8ec89 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -8,7 +8,6 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ApiFactory } from '@backstage/frontend-plugin-api'; import { AppTheme } from '@backstage/frontend-plugin-api'; -import { ComponentRef } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; @@ -23,6 +22,7 @@ import { NavContentComponent } from '@backstage/frontend-plugin-api'; import { ReactNode } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SignInPageProps } from '@backstage/core-plugin-api'; +import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; import { TranslationMessages } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; @@ -315,36 +315,6 @@ const appPlugin: FrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; - 'api:app/components': ExtensionDefinition<{ - config: {}; - configInput: {}; - output: ExtensionDataRef; - inputs: { - components: ExtensionInput< - ConfigurableExtensionDataRef< - { - ref: ComponentRef; - impl: ComponentType; - }, - 'core.component.component', - {} - >, - { - singleton: false; - optional: false; - } - >; - }; - kind: 'api'; - name: 'components'; - params: < - TApi, - TImpl extends TApi, - TDeps extends { [name in string]: unknown }, - >( - params: ApiFactory, - ) => ExtensionBlueprintParams; - }>; 'api:app/dialog': ExtensionDefinition<{ kind: 'api'; name: 'dialog'; @@ -614,6 +584,38 @@ const appPlugin: FrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:app/swappable-components': ExtensionDefinition<{ + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: { + components: ExtensionInput< + ConfigurableExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >, + { + singleton: false; + optional: false; + } + >; + }; + kind: 'api'; + name: 'swappable-components'; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:app/translations': ExtensionDefinition<{ config: {}; configInput: {}; @@ -726,6 +728,174 @@ const appPlugin: FrontendPlugin< element: JSX.Element; }; }>; + 'component:app/core.components.errorBoundary': ExtensionDefinition<{ + kind: 'component'; + name: 'core.components.errorBoundary'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }>; + }>; + 'component:app/core.components.notFoundErrorPage': ExtensionDefinition<{ + kind: 'component'; + name: 'core.components.notFoundErrorPage'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }>; + }>; + 'component:app/core.components.progress': ExtensionDefinition<{ + kind: 'component'; + name: 'core.components.progress'; + config: {}; + configInput: {}; + output: ExtensionDataRef< + { + ref: SwappableComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.swappableComponent', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element | null) + : never; + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }>; + }>; 'sign-in-page:app': ExtensionDefinition<{ kind: 'sign-in-page'; name: undefined; diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 1f15ced3ea..5cc3c3f4b8 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -18,8 +18,7 @@ import { createExtension, coreExtensionData, createExtensionInput, - coreComponentRefs, - useComponentRef, + NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; import { useRoutes } from 'react-router-dom'; @@ -36,10 +35,6 @@ export const AppRoutes = createExtension({ output: [coreExtensionData.reactElement], factory({ inputs }) { const Routes = () => { - const NotFoundErrorPage = useComponentRef( - coreComponentRefs.notFoundErrorPage, - ); - const element = useRoutes([ ...inputs.routes.map(route => ({ path: `${route diff --git a/plugins/app/src/extensions/ComponentsApi.tsx b/plugins/app/src/extensions/SwappableComponentsApi.ts similarity index 66% rename from plugins/app/src/extensions/ComponentsApi.tsx rename to plugins/app/src/extensions/SwappableComponentsApi.ts index ffba964bc1..3964fc172a 100644 --- a/plugins/app/src/extensions/ComponentsApi.tsx +++ b/plugins/app/src/extensions/SwappableComponentsApi.ts @@ -15,34 +15,33 @@ */ import { - createComponentExtension, + SwappableComponentBlueprint, createExtensionInput, ApiBlueprint, - componentsApiRef, + swappableComponentsApiRef, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { DefaultComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/ComponentsApi'; +import { DefaultSwappableComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi'; /** * Contains the shareable components installed into the app. */ -export const ComponentsApi = ApiBlueprint.makeWithOverrides({ - name: 'components', +export const SwappableComponentsApi = ApiBlueprint.makeWithOverrides({ + name: 'swappable-components', inputs: { - components: createExtensionInput( - [createComponentExtension.componentDataRef], - { replaces: [{ id: 'app', input: 'components' }] }, - ), + components: createExtensionInput([ + SwappableComponentBlueprint.dataRefs.component, + ]), }, factory: (originalFactory, { inputs }) => { return originalFactory(defineParams => defineParams({ - api: componentsApiRef, + api: swappableComponentsApiRef, deps: {}, factory: () => - DefaultComponentsApi.fromComponents( + DefaultSwappableComponentsApi.fromComponents( inputs.components.map(i => - i.get(createComponentExtension.componentDataRef), + i.get(SwappableComponentBlueprint.dataRefs.component), ), ), }), diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index c5fa8411c7..782e457b29 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2025 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. @@ -13,41 +13,54 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import Button from '@material-ui/core/Button'; +import { + NotFoundErrorPage as SwappableNotFoundErrorPage, + Progress as SwappableProgress, + ErrorDisplay as SwappableErrorDisplay, + SwappableComponentBlueprint, +} from '@backstage/frontend-plugin-api'; import { - createComponentExtension, - coreComponentRefs, -} 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 '../../../../packages/app-defaults/src/defaults'; + ErrorPage, + ErrorPanel, + Progress as ProgressComponent, +} from '@backstage/core-components'; +import Button from '@material-ui/core/Button'; -export const DefaultProgressComponent = createComponentExtension({ - ref: coreComponentRefs.progress, - loader: { sync: () => defaultComponents.Progress }, +export const Progress = SwappableComponentBlueprint.make({ + name: 'core.components.progress', + params: define => + define({ + component: SwappableProgress, + loader: () => ProgressComponent, + }), }); -export const DefaultNotFoundErrorPageComponent = createComponentExtension({ - ref: coreComponentRefs.notFoundErrorPage, - loader: { sync: () => defaultComponents.NotFoundErrorPage }, +export const NotFoundErrorPage = SwappableComponentBlueprint.make({ + name: 'core.components.notFoundErrorPage', + params: define => + define({ + component: SwappableNotFoundErrorPage, + loader: () => () => + , + }), }); -export const DefaultErrorBoundaryComponent = createComponentExtension({ - ref: coreComponentRefs.errorBoundaryFallback, - loader: { - sync: () => props => { - const { plugin, error, resetError } = props; - const title = `Error in ${plugin?.id}`; - - return ( - - - - ); - }, - }, +export const ErrorBoundary = SwappableComponentBlueprint.make({ + name: 'core.components.errorBoundary', + params: define => + define({ + component: SwappableErrorDisplay, + loader: () => props => { + const { plugin, error, resetError } = props; + const title = `Error in ${plugin?.id}`; + return ( + + + + ); + }, + }), }); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 090e7c2cae..3eea920bbd 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -20,18 +20,14 @@ export { AppNav } from './AppNav'; export { AppRoot } from './AppRoot'; export { AppRoutes } from './AppRoutes'; export { AppThemeApi, DarkTheme, LightTheme } from './AppThemeApi'; -export { ComponentsApi } from './ComponentsApi'; +export { SwappableComponentsApi } from './SwappableComponentsApi'; export { IconsApi } from './IconsApi'; export { FeatureFlagsApi } from './FeatureFlagsApi'; export { TranslationsApi } from './TranslationsApi'; export { DefaultSignInPage } from './DefaultSignInPage'; export { dialogDisplayAppRootElement } from './DialogDisplay'; -export { - DefaultProgressComponent, - DefaultErrorBoundaryComponent, - DefaultNotFoundErrorPageComponent, -} from './components'; export { oauthRequestDialogAppRootElement, alertDisplayAppRootElement, } from './elements'; +export { Progress, NotFoundErrorPage, ErrorBoundary } from './components'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index a1e6602410..c898334430 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -25,17 +25,17 @@ import { AppThemeApi, DarkTheme, LightTheme, - ComponentsApi, + SwappableComponentsApi, IconsApi, FeatureFlagsApi, TranslationsApi, - DefaultProgressComponent, - DefaultNotFoundErrorPageComponent, - DefaultErrorBoundaryComponent, oauthRequestDialogAppRootElement, alertDisplayAppRootElement, DefaultSignInPage, dialogDisplayAppRootElement, + Progress, + NotFoundErrorPage, + ErrorBoundary, } from './extensions'; import { apis } from './defaultApis'; @@ -54,16 +54,16 @@ export const appPlugin = createFrontendPlugin({ AppThemeApi, DarkTheme, LightTheme, - ComponentsApi, + SwappableComponentsApi, IconsApi, FeatureFlagsApi, TranslationsApi, - DefaultProgressComponent, - DefaultNotFoundErrorPageComponent, - DefaultErrorBoundaryComponent, DefaultSignInPage, oauthRequestDialogAppRootElement, alertDisplayAppRootElement, dialogDisplayAppRootElement, + Progress, + NotFoundErrorPage, + ErrorBoundary, ], }); diff --git a/yarn.lock b/yarn.lock index 22a4100216..b897f6e81f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4367,6 +4367,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-test-utils": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" @@ -32293,12 +32294,12 @@ __metadata: languageName: node linkType: hard -"for-each@npm:^0.3.3": - version: 0.3.3 - resolution: "for-each@npm:0.3.3" +"for-each@npm:^0.3.3, for-each@npm:^0.3.5": + version: 0.3.5 + resolution: "for-each@npm:0.3.5" dependencies: - is-callable: "npm:^1.1.3" - checksum: 10/fdac0cde1be35610bd635ae958422e8ce0cc1313e8d32ea6d34cfda7b60850940c1fd07c36456ad76bd9c24aef6ff5e03b02beb58c83af5ef6c968a64eada676 + is-callable: "npm:^1.2.7" + checksum: 10/330cc2439f85c94f4609de3ee1d32c5693ae15cdd7fe3d112c4fd9efd4ce7143f2c64ef6c2c9e0cfdb0058437f33ef05b5bdae5b98fcc903fb2143fbaf0fea0f languageName: node linkType: hard @@ -34905,7 +34906,7 @@ __metadata: languageName: node linkType: hard -"is-callable@npm:^1.1.3, is-callable@npm:^1.1.5, is-callable@npm:^1.2.7": +"is-callable@npm:^1.1.5, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" checksum: 10/48a9297fb92c99e9df48706241a189da362bff3003354aea4048bd5f7b2eb0d823cd16d0a383cece3d76166ba16d85d9659165ac6fcce1ac12e6c649d66dbdb9 @@ -45106,14 +45107,16 @@ __metadata: linkType: hard "regexp.prototype.flags@npm:^1.5.3": - version: 1.5.3 - resolution: "regexp.prototype.flags@npm:1.5.3" + version: 1.5.4 + resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: - call-bind: "npm:^1.0.7" + call-bind: "npm:^1.0.8" define-properties: "npm:^1.2.1" es-errors: "npm:^1.3.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" set-function-name: "npm:^2.0.2" - checksum: 10/fe17bc4eebbc72945aaf9dd059eb7784a5ca453a67cc4b5b3e399ab08452c9a05befd92063e2c52e7b24d9238c60031656af32dd57c555d1ba6330dbf8c23b43 + checksum: 10/8ab897ca445968e0b96f6237641510f3243e59c180ee2ee8d83889c52ff735dd1bf3657fcd36db053e35e1d823dd53f2565d0b8021ea282c9fe62401c6c3bd6d languageName: node linkType: hard @@ -50787,16 +50790,17 @@ __metadata: linkType: hard "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": - version: 1.1.18 - resolution: "which-typed-array@npm:1.1.18" + version: 1.1.19 + resolution: "which-typed-array@npm:1.1.19" dependencies: available-typed-arrays: "npm:^1.0.7" call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" - for-each: "npm:^0.3.3" + call-bound: "npm:^1.0.4" + for-each: "npm:^0.3.5" + get-proto: "npm:^1.0.1" gopd: "npm:^1.2.0" has-tostringtag: "npm:^1.0.2" - checksum: 10/11eed801b2bd08cdbaecb17aff381e0fb03526532f61acc06e6c7b9370e08062c33763a51f27825f13fdf34aabd0df6104007f4e8f96e6eaef7db0ce17a26d6e + checksum: 10/12be30fb88567f9863186bee1777f11bea09dd59ed8b3ce4afa7dd5cade75e2f4cc56191a2da165113cc7cf79987ba021dac1e22b5b62aa7e5c56949f2469a68 languageName: node linkType: hard