diff --git a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx index a636b2b220..67746f6c68 100644 --- a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx +++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx @@ -14,13 +14,11 @@ * limitations under the License. */ -import { - createComponentExtension, - coreComponentRefs, -} from '@backstage/frontend-plugin-api'; +import { AdaptableComponentBlueprint } 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'; +import { NotFoundErrorPage } from '@backstage/plugin-app'; export function CustomNotFoundErrorPage() { return ( @@ -50,8 +48,11 @@ export function CustomNotFoundErrorPage() { ); } -export default createComponentExtension({ +export default AdaptableComponentBlueprint.make({ name: 'not-found-error-page', - ref: coreComponentRefs.notFoundErrorPage, - loader: { sync: () => 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..2f0cd451e2 100644 --- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx @@ -24,8 +24,6 @@ import { createFrontendPlugin as createNewPlugin, FrontendPlugin as NewFrontendPlugin, appTreeApiRef, - componentsApiRef, - coreComponentRefs, iconsApiRef, useApi, routeResolutionApiRef, @@ -44,6 +42,9 @@ import { } from '@backstage/version-bridge'; import { convertLegacyRouteRef } from '../convertLegacyRouteRef'; +import { ErrorBoundary, NotFoundErrorPage } from '@backstage/plugin-app'; +import { Progress } from '@backstage/core-components'; + // Make sure that we only convert each new plugin instance to its legacy equivalent once const legacyPluginStore = getOrCreateGlobalSingleton( 'legacy-plugin-compatibility-store', @@ -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/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx index f6c4e163ad..4895f3234c 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 { + AdaptableComponentBlueprint, ApiBlueprint, - coreComponentRefs, CoreErrorBoundaryFallbackProps, - createComponentExtension, createExtension, createFrontendModule, ExtensionDefinition, @@ -39,6 +38,11 @@ import { } from '@backstage/core-plugin-api'; import { toLegacyPlugin } from './compatWrapper/BackwardsCompatProvider'; import { compatWrapper } from './compatWrapper'; +import { Progress as AdaptableProgress } from '@backstage/core-components'; +import { + NotFoundErrorPage as AdaptableNotFoundErrorPage, + ErrorBoundary as AdaptableErrorBoundary, +} from '@backstage/plugin-app'; function componentCompatWrapper( Component: ComponentType, @@ -154,20 +158,27 @@ export function convertLegacyAppOptions( } if (Progress) { extensions.push( - createComponentExtension({ - ref: coreComponentRefs.progress, - loader: { sync: () => componentCompatWrapper(Progress) }, + AdaptableComponentBlueprint.make({ + params: define => + define({ + component: AdaptableProgress, + loader: () => componentCompatWrapper(Progress), + }), }), ); } if (NotFoundErrorPage) { extensions.push( - createComponentExtension({ - ref: coreComponentRefs.notFoundErrorPage, - loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) }, + AdaptableComponentBlueprint.make({ + params: define => + define({ + component: AdaptableNotFoundErrorPage, + loader: () => componentCompatWrapper(NotFoundErrorPage), + }), }), ); } + if (ErrorBoundaryFallback) { const WrappedErrorBoundaryFallback = ( props: CoreErrorBoundaryFallbackProps, @@ -178,12 +189,16 @@ export function convertLegacyAppOptions( plugin={props.plugin && toLegacyPlugin(props.plugin)} />, ); + extensions.push( - createComponentExtension({ - ref: coreComponentRefs.errorBoundaryFallback, - loader: { - sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback), - }, + AdaptableComponentBlueprint.make({ + params: define => + define({ + component: AdaptableErrorBoundary, + loader: () => + // todo: types + props lols + componentCompatWrapper(WrappedErrorBoundaryFallback), + }), }), ); } diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx index b7176a5825..27706cb7d1 100644 --- a/packages/core-components/src/components/Progress/Progress.tsx +++ b/packages/core-components/src/components/Progress/Progress.tsx @@ -21,7 +21,10 @@ import LinearProgress, { import { useTheme } from '@material-ui/core/styles'; import { PropsWithChildren, useEffect, useState } from 'react'; -export function Progress(props: PropsWithChildren) { +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { createAdaptableComponent } from '../../../../frontend-plugin-api/src/components/createAdaptableComponent'; + +function ProgressComponent(props: PropsWithChildren) { const theme = useTheme(); const [isVisible, setIsVisible] = useState(false); @@ -39,3 +42,8 @@ export function Progress(props: PropsWithChildren) { ); } + +export const Progress = createAdaptableComponent({ + id: 'core.components.progress', + loader: () => ProgressComponent, +}); 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 index 7d06d55a92..e7491308b8 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx @@ -14,24 +14,25 @@ * limitations under the License. */ -import { createComponentRef } from '@backstage/frontend-plugin-api'; +import { createAdaptableComponent } 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' }); +const { ref: testRefA } = createAdaptableComponent({ id: 'test.a' }); +const { ref: testRefB1 } = createAdaptableComponent({ id: 'test.b' }); +const { ref: testRefB2 } = createAdaptableComponent({ id: 'test.b' }); describe('DefaultComponentsApi', () => { it('should provide components', () => { const api = DefaultComponentsApi.fromComponents([ { ref: testRefA, - impl: () =>
test.a
, + loader: () => () =>
test.a
, }, ]); - const ComponentA = api.getComponent(testRefA); + const ComponentA = api.getComponent(testRefA)?.() as () => JSX.Element; + render(); expect(screen.getByText('test.a')).toBeInTheDocument(); @@ -41,12 +42,12 @@ describe('DefaultComponentsApi', () => { const api = DefaultComponentsApi.fromComponents([ { ref: testRefB1, - impl: () =>
test.b
, + loader: () => () =>
test.b
, }, ]); - const ComponentB1 = api.getComponent(testRefB1); - const ComponentB2 = api.getComponent(testRefB2); + const ComponentB1 = api.getComponent(testRefB1)?.() as () => JSX.Element; + const ComponentB2 = api.getComponent(testRefB2)?.() as () => JSX.Element; expect(ComponentB1).toBe(ComponentB2); diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts index 317233d797..88265955d6 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts @@ -14,11 +14,10 @@ * limitations under the License. */ -import { ComponentType } from 'react'; import { ComponentRef, ComponentsApi, - createComponentExtension, + AdaptableComponentBlueprint, } from '@backstage/frontend-plugin-api'; /** @@ -27,13 +26,18 @@ import { * @internal */ export class DefaultComponentsApi implements ComponentsApi { - #components: Map>; + #components: Map< + string, + | (() => (props: object) => JSX.Element | null) + | (() => Promise<(props: object) => JSX.Element | null>) + | undefined + >; static fromComponents( - components: Array, + components: Array, ) { return new DefaultComponentsApi( - new Map(components.map(entry => [entry.ref.id, entry.impl])), + new Map(components.map(entry => [entry.ref.id, entry.loader])), ); } @@ -41,11 +45,13 @@ export class DefaultComponentsApi implements ComponentsApi { this.#components = components; } - getComponent(ref: ComponentRef): ComponentType { + getComponent( + ref: ComponentRef, + ): + | (() => (props: object) => JSX.Element | null) + | (() => Promise<(props: object) => JSX.Element | null>) + | undefined { 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-internal/src/wiring/InternalComponentRef.ts b/packages/frontend-internal/src/wiring/InternalComponentRef.ts index 00d4a253d5..edd1355b76 100644 --- a/packages/frontend-internal/src/wiring/InternalComponentRef.ts +++ b/packages/frontend-internal/src/wiring/InternalComponentRef.ts @@ -21,12 +21,10 @@ export const OpaqueComponentRef = OpaqueType.create<{ public: ComponentRef; versions: { readonly version: 'v1'; - readonly options: { - transformProps?: (props: object) => object; - loader?: - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>); - }; + readonly transformProps?: (props: object) => object; + readonly loader?: + | (() => (props: object) => JSX.Element | null) + | (() => Promise<(props: object) => JSX.Element | null>); }; }>({ versions: ['v1'], diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts index c5e9d2c44f..0a46e1d018 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { ComponentType } from 'react'; -import { createApiRef, useApi } from '@backstage/core-plugin-api'; import { ComponentRef } from '../../components'; +import { createApiRef } from '../system/ApiRef'; /** * API for looking up components based on component refs. @@ -24,8 +23,15 @@ import { ComponentRef } from '../../components'; * @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; + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: ComponentRef, + ): + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) + | undefined; } /** @@ -36,14 +42,3 @@ export interface ComponentsApi { 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/blueprints/ComponentImplementationBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx similarity index 79% rename from packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx rename to packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx index cd109a6562..e52076896f 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ import { renderInTestApp } from '@backstage/frontend-test-utils'; -import { createComponentRef } from '../components'; -import { makeComponentFromRef } from '../components/makeComponentFromRef'; -import { ComponentImplementationBlueprint } from './ComponentImplementationBlueprint'; +import { createAdaptableComponent } from '../components'; +import { AdaptableComponentBlueprint } from './AdaptableComponentBlueprint'; import { PageBlueprint } from './PageBlueprint'; import { waitFor, screen } from '@testing-library/react'; -describe('ComponentImplementationBlueprint', () => { +describe('AdaptableComponentBlueprint', () => { it('should allow defining a component override for a component ref', () => { - const componentRef = createComponentRef({ + const Component = createAdaptableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); - const extension = ComponentImplementationBlueprint.make({ + const extension = AdaptableComponentBlueprint.make({ params: define => define({ - ref: componentRef, + component: Component, loader: () => props => { // @ts-expect-error const t: number = props.hello; @@ -44,13 +43,11 @@ describe('ComponentImplementationBlueprint', () => { }); it('should render default component refs in the app', async () => { - const testComponentRef = createComponentRef({ + const TestComponent = createAdaptableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); - const TestComponent = makeComponentFromRef({ ref: testComponentRef }); - renderInTestApp(
, { extensions: [ PageBlueprint.make({ @@ -69,12 +66,10 @@ describe('ComponentImplementationBlueprint', () => { }); it('should render a component ref without a default implementation', async () => { - const testComponentRef = createComponentRef({ + const TestComponent = createAdaptableComponent({ id: 'test.component', }); - const TestComponent = makeComponentFromRef({ ref: testComponentRef }); - renderInTestApp(
, { extensions: [ PageBlueprint.make({ @@ -94,14 +89,12 @@ describe('ComponentImplementationBlueprint', () => { }); it('should render a component ref with an async loader implementation', async () => { - const testComponentRef = createComponentRef({ + const TestComponent = createAdaptableComponent({ id: 'test.component', loader: async () => (props: { hello: string }) =>
{props.hello}
, }); - const TestComponent = makeComponentFromRef({ ref: testComponentRef }); - renderInTestApp(
, { extensions: [ PageBlueprint.make({ @@ -120,17 +113,15 @@ describe('ComponentImplementationBlueprint', () => { }); it('should allow overriding a component ref with the blueprint', async () => { - const testComponentRef = createComponentRef({ + const TestComponent = createAdaptableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); - const TestComponent = makeComponentFromRef({ ref: testComponentRef }); - - const extension = ComponentImplementationBlueprint.make({ + const extension = AdaptableComponentBlueprint.make({ params: define => define({ - ref: testComponentRef, + component: TestComponent, loader: () => props =>
Override {props.hello}
, }), }); diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts similarity index 85% rename from packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts rename to packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts index 5d2ddea25e..457a04327c 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts @@ -27,7 +27,7 @@ export const componentDataRef = createExtensionDataRef<{ | (() => Promise<(props: {}) => JSX.Element | null>); }>().with({ id: 'core.component.component' }); -export const ComponentImplementationBlueprint = createExtensionBlueprint({ +export const AdaptableComponentBlueprint = createExtensionBlueprint({ kind: 'component', attachTo: { id: 'api:app/components', input: 'components' }, output: [componentDataRef], @@ -35,7 +35,9 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({ component: componentDataRef, }, defineParams>(params: { - ref: Ref; + component: Ref extends ComponentRef + ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element) + : never; loader: Ref extends ComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) @@ -46,7 +48,7 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({ }, factory: params => [ componentDataRef({ - ref: params.ref, + 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..b7164beab3 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 { AdaptableComponentBlueprint } from './AdaptableComponentBlueprint'; diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index f653fa1742..e4e7e05b0f 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -25,8 +25,8 @@ 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 { Progress } from '@backstage/core-components'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; @@ -66,8 +66,9 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { ); const plugin = node.spec.plugin; - const Progress = useComponentRef(coreComponentRefs.progress); - const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback); + + // todo: fallback + const fallback = () =>
Fallback
; // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { 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/makeComponentFromRef.test.tsx b/packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx similarity index 65% rename from packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx rename to packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx index a35ede3e09..0712b4a5e0 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Backstage Authors + * 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. @@ -13,14 +13,61 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { render, screen } from '@testing-library/react'; -import { createComponentRef } from './createComponentRef'; -import { makeComponentFromRef } from './makeComponentFromRef'; -describe('makeComponentFromRef', () => { +import { render, screen } from '@testing-library/react'; +import { createAdaptableComponent } from './createAdaptableComponent'; + +describe('createAdaptableComponent', () => { + it('can be created and read', () => { + const { ref } = createAdaptableComponent({ id: 'foo' }); + expect(ref.id).toBe('foo'); + expect(String(ref)).toBe('ComponentRef{id=foo}'); + }); + + it('should allow defining a default component implementation', () => { + const Test = () =>
test
; + + createAdaptableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + loader: + () => + ({ foo }) => + , + }); + + createAdaptableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + loader: + async () => + ({ foo }) => + , + }); + + createAdaptableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + }); + + expect(Test).toBeDefined(); + }); + + it('should allow transformings props', () => { + createAdaptableComponent<{ foo: string }, { bar: string }>({ + id: 'foo', + transformProps: props => ({ foo: props.bar }), + }); + + createAdaptableComponent<{ 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', () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', loader: () => (props: { name: string }) => { return
{props.name}
; @@ -30,26 +77,23 @@ describe('makeComponentFromRef', () => { }), }); - const Component = makeComponentFromRef({ ref }); render(); expect(screen.getByTestId('test')).toHaveTextContent('test'); }); it('should render a fallback when theres no default implementation provided', () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', }); - const Component = makeComponentFromRef({ ref }); - render(); expect(screen.getByTestId('random')).toBeInTheDocument(); }); it('should map props from external to internal', () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), @@ -62,8 +106,6 @@ describe('makeComponentFromRef', () => { }, }); - const Component = makeComponentFromRef({ ref }); - render(); expect(screen.getByTestId('test')).toHaveTextContent('TEST'); @@ -72,34 +114,30 @@ describe('makeComponentFromRef', () => { describe('async', () => { it('should create a component from a ref for async component', async () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', loader: async () => (props: { name: string }) => { return
{props.name}
; }, }); - const Component = makeComponentFromRef({ ref }); - render(); await expect(screen.findByTestId('test')).resolves.toBeInTheDocument(); }); it('should render a fallback when theres no default implementation provided', async () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', }); - const Component = makeComponentFromRef({ ref }); - render(); await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); }); it('should map props from external to internal', async () => { - const ref = createComponentRef({ + const Component = createAdaptableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), @@ -112,8 +150,6 @@ describe('makeComponentFromRef', () => { }, }); - const Component = makeComponentFromRef({ ref }); - render(); await expect(screen.findByTestId('test')).resolves.toHaveTextContent( diff --git a/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx b/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx new file mode 100644 index 0000000000..3a454d5c7e --- /dev/null +++ b/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx @@ -0,0 +1,119 @@ +/* + * 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 { OpaqueComponentRef } from '@internal/frontend'; +import { componentsApiRef, useApi } from '../apis'; +import { lazy, Suspense } from 'react'; + +/** @public */ +export type ComponentRef< + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/ComponentRef'; +}; + +export type ComponentRefOptions< + 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(componentsApiRef); + } catch (e) { + return undefined; + } +}; + +function makeComponentFromRef< + InternalComponentProps extends {}, + ExternalComponentProps extends {}, +>({ + ref, +}: { + ref: ComponentRef; +}): (props: ExternalComponentProps) => JSX.Element { + const internalRef = OpaqueComponentRef.toInternal(ref); + const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( +
+ ); + + const ComponentRefImpl = (props: ExternalComponentProps) => { + const api = useComponentRefApi(); + const ComponentOrPromise = + api?.getComponent(ref)?.() ?? + internalRef.loader?.() ?? + FallbackComponent; + + const innerProps = internalRef.transformProps?.(props) ?? props; + + if ('then' in ComponentOrPromise) { + const DefaultImplementation = lazy(() => + ComponentOrPromise.then(c => { + return { default: c }; + }), + ); + + return ( + + + + ); + } + + return ; + }; + + return ComponentRefImpl; +} + +export function createAdaptableComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +>( + options: ComponentRefOptions, +): ((props: TExternalComponentProps) => JSX.Element) & { + ref: ComponentRef; +} { + const ref = OpaqueComponentRef.createInstance('v1', { + id: options.id, + TProps: null as unknown as TInnerComponentProps, + TExternalProps: null as unknown as TExternalComponentProps, + toString() { + return `ComponentRef{id=${options.id}}`; + }, + loader: options.loader as (typeof OpaqueComponentRef.TInternal)['loader'], + transformProps: + options.transformProps as (typeof OpaqueComponentRef.TInternal)['transformProps'], + }); + + const component = makeComponentFromRef({ ref }); + Object.assign(component, { ref }); + + return component as { + ref: ComponentRef; + } & ((props: object) => JSX.Element); +} diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx deleted file mode 100644 index b1bcd66c17..0000000000 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ /dev/null @@ -1,66 +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 './createComponentRef'; - -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}'); - }); - - it('should allow defining a default component implementation', () => { - const Test = () =>
test
; - - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - loader: - () => - ({ foo }) => - , - }); - - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - loader: - async () => - ({ foo }) => - , - }); - - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - }); - - expect(Test).toBeDefined(); - }); - - it('should allow transformings props', () => { - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - transformProps: props => ({ foo: props.bar }), - }); - - createComponentRef<{ 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); - }); -}); 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 7c4f027fa6..0000000000 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ /dev/null @@ -1,59 +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 { OpaqueComponentRef } from '@internal/frontend'; - -/** @public */ -export type ComponentRef< - TInnerComponentProps extends {} = {}, - TExternalComponentProps extends {} = TInnerComponentProps, -> = { - id: string; - TProps: TInnerComponentProps; - TExternalProps: TExternalComponentProps; - $$type: '@backstage/ComponentRef'; -}; - -export type ComponentRefOptions< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, -> = { - id: string; - loader?: - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); - transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; -}; - -export function createComponentRef< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, ->( - options: ComponentRefOptions, -): ComponentRef { - return OpaqueComponentRef.createInstance('v1', { - id: options.id, - TProps: null as unknown as TInnerComponentProps, - TExternalProps: null as unknown as TExternalComponentProps, - toString() { - return `ComponentRef{id=${options.id}}`; - }, - options: { - loader: options.loader, - transformProps: options.transformProps, - } as (typeof OpaqueComponentRef.TInternal)['options'], - }); -} diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 31a53be24c..9c50ba0c43 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -18,6 +18,8 @@ export { ExtensionBoundary, type ExtensionBoundaryProps, } from './ExtensionBoundary'; -export { coreComponentRefs } from './coreComponentRefs'; -export { createComponentRef, type ComponentRef } from './createComponentRef'; +export { + createAdaptableComponent, + type ComponentRef, +} from './createAdaptableComponent'; export { useAppNode } from './AppNodeProvider'; diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx deleted file mode 100644 index 7953549fbc..0000000000 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 { lazy, Suspense } from 'react'; -import { ComponentRef } from './createComponentRef'; -import { OpaqueComponentRef } from '@internal/frontend'; -import { componentsApiRef, useApi } from '../apis'; - -export function makeComponentFromRef< - InternalComponentProps extends {}, - ExternalComponentProps extends {}, ->({ - ref, -}: { - ref: ComponentRef; -}): (props: ExternalComponentProps) => JSX.Element { - const { options } = OpaqueComponentRef.toInternal(ref); - const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( -
- ); - - const ComponentRefImpl = (props: ExternalComponentProps) => { - // todo(blam): use the component that's in the API ref instead if it's defined. - // otherwise use the fallback.. - const api = useApi(componentsApiRef); - const innerProps = options.transformProps?.(props) ?? props; - - const ComponentOrPromise = options.loader?.() ?? FallbackComponent; - - if ('then' in ComponentOrPromise) { - const DefaultImplementation = lazy(() => - ComponentOrPromise.then(c => { - return { default: c }; - }), - ); - - return ( - // todo: is this necessary? can we remove this? - - - - ); - } - - return ; - }; - - return ComponentRefImpl; -} 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..f3c633c959 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'; diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 1f15ced3ea..6291f24949 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -18,10 +18,9 @@ import { createExtension, coreExtensionData, createExtensionInput, - coreComponentRefs, - useComponentRef, } from '@backstage/frontend-plugin-api'; import { useRoutes } from 'react-router-dom'; +import { NotFoundErrorPage } from './components'; export const AppRoutes = createExtension({ name: 'routes', @@ -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/ComponentsApi.tsx index ffba964bc1..1717872a22 100644 --- a/plugins/app/src/extensions/ComponentsApi.tsx +++ b/plugins/app/src/extensions/ComponentsApi.tsx @@ -15,7 +15,7 @@ */ import { - createComponentExtension, + AdaptableComponentBlueprint, createExtensionInput, ApiBlueprint, componentsApiRef, @@ -30,7 +30,7 @@ export const ComponentsApi = ApiBlueprint.makeWithOverrides({ name: 'components', inputs: { components: createExtensionInput( - [createComponentExtension.componentDataRef], + [AdaptableComponentBlueprint.dataRefs.component], { replaces: [{ id: 'app', input: 'components' }] }, ), }, @@ -42,7 +42,7 @@ export const ComponentsApi = ApiBlueprint.makeWithOverrides({ factory: () => DefaultComponentsApi.fromComponents( inputs.components.map(i => - i.get(createComponentExtension.componentDataRef), + i.get(AdaptableComponentBlueprint.dataRefs.component), ), ), }), diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index c5fa8411c7..6dd46bcd6e 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -14,40 +14,14 @@ * limitations under the License. */ -import Button from '@material-ui/core/Button'; +import { createAdaptableComponent } 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'; - -export const DefaultProgressComponent = createComponentExtension({ - ref: coreComponentRefs.progress, - loader: { sync: () => defaultComponents.Progress }, +export const NotFoundErrorPage = createAdaptableComponent({ + id: 'core.components.notFoundErrorPage', + // todo: implementation }); -export const DefaultNotFoundErrorPageComponent = createComponentExtension({ - ref: coreComponentRefs.notFoundErrorPage, - loader: { sync: () => defaultComponents.NotFoundErrorPage }, -}); - -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 = createAdaptableComponent({ + id: 'core.components.errorBoundaryFallback', + // todo: implementation }); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 090e7c2cae..487494601b 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -26,11 +26,8 @@ export { FeatureFlagsApi } from './FeatureFlagsApi'; export { TranslationsApi } from './TranslationsApi'; export { DefaultSignInPage } from './DefaultSignInPage'; export { dialogDisplayAppRootElement } from './DialogDisplay'; -export { - DefaultProgressComponent, - DefaultErrorBoundaryComponent, - DefaultNotFoundErrorPageComponent, -} from './components'; +// todo: move these +export { ErrorBoundary, NotFoundErrorPage } from './components'; export { oauthRequestDialogAppRootElement, alertDisplayAppRootElement, diff --git a/plugins/app/src/index.ts b/plugins/app/src/index.ts index 24f65df618..d2614dcf66 100644 --- a/plugins/app/src/index.ts +++ b/plugins/app/src/index.ts @@ -15,3 +15,5 @@ */ export { appPlugin as default } from './plugin'; +// todo: remove +export * from './extensions/components'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index a1e6602410..09596a7385 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -29,9 +29,6 @@ import { IconsApi, FeatureFlagsApi, TranslationsApi, - DefaultProgressComponent, - DefaultNotFoundErrorPageComponent, - DefaultErrorBoundaryComponent, oauthRequestDialogAppRootElement, alertDisplayAppRootElement, DefaultSignInPage, @@ -58,9 +55,6 @@ export const appPlugin = createFrontendPlugin({ IconsApi, FeatureFlagsApi, TranslationsApi, - DefaultProgressComponent, - DefaultNotFoundErrorPageComponent, - DefaultErrorBoundaryComponent, DefaultSignInPage, oauthRequestDialogAppRootElement, alertDisplayAppRootElement,