From 10ad6cde0502d4e9fd5b9c38cd3aab2a80c1263d Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 25 Jul 2025 17:44:30 +0200 Subject: [PATCH 01/29] chore: started initial work on implementing interal vs external props Signed-off-by: benjdlambert --- .../components/createComponentRef.test.tsx | 55 ++++++++++++++ .../src/components/createComponentRef.tsx | 76 +++++++++++++++++-- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx index 9a84fe08fa..e20a35ff84 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx @@ -22,4 +22,59 @@ describe('createComponentRef', () => { 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', + mode: 'sync', + defaultComponent: ({ bar }) => , + }); + + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + mode: 'async', + defaultComponent: async () => , + }); + + // @ts-expect-error - this should be an error as mode is sync + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + mode: 'sync', + defaultComponent: async ({ bar }) => , + }); + + // todo: why do we have two errors here? + // @ts-expect-error - this should be an error as mode is async + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + mode: 'async', + defaultComponent: ({ bar }) => , + }); + + // todo: why does this not work? + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + // @ts-expect-error - this should be an error as default mode is async + defaultComponent: ({ bar }) => , + }); + + 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 index 88181dd079..9f36e7f21e 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -15,20 +15,82 @@ */ /** @public */ -export type ComponentRef = { +export type ComponentRef< + TInnerComponentProps, + TExternalComponentProps, + TMode extends 'sync' | 'async' = 'async', +> = { id: string; - T: T; + mode: TMode; + transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; + defaultComponent?: TMode extends 'async' + ? (props: TExternalComponentProps) => Promise + : (props: TExternalComponentProps) => JSX.Element; }; -/** @public */ -export function createComponentRef(options: { +export interface ComponentRefOptions< + TInnerComponentProps, + TExternalComponentProps, + TMode extends 'sync' | 'async' = 'async', +> { id: string; -}): ComponentRef { - const { id } = options; + mode?: TMode; + defaultComponent?: TMode extends 'async' + ? (props: TExternalComponentProps) => Promise + : (props: TExternalComponentProps) => JSX.Element; + transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; +} + +/** + * Creates a new component ref that is synchronous. + * @public + */ +export function createComponentRef< + TInnerComponentProps, + TExternalComponentProps, +>( + options: ComponentRefOptions< + TInnerComponentProps, + TExternalComponentProps, + 'sync' + >, +): ComponentRef; + +/** + * Creates a new component ref that is asynchronous. + * @public + */ +export function createComponentRef< + TInnerComponentProps, + TExternalComponentProps, +>( + options: ComponentRefOptions< + TInnerComponentProps, + TExternalComponentProps, + 'async' + >, +): ComponentRef; + +export function createComponentRef< + TInnerComponentProps, + TExternalComponentProps, + TMode extends 'sync' | 'async', +>( + options: ComponentRefOptions< + TInnerComponentProps, + TExternalComponentProps, + TMode + >, +): ComponentRef { + const { id, mode = 'async', defaultComponent, transformProps } = options; + return { id, + mode, + defaultComponent, + transformProps, toString() { return `ComponentRef{id=${id}}`; }, - } as ComponentRef; + } as ComponentRef; } From 97a9f5cd320c2a553593af0a29ae8211b0896ace Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 28 Jul 2025 10:31:07 +0200 Subject: [PATCH 02/29] chore: fixing sync mode Signed-off-by: benjdlambert --- .../src/components/createComponentRef.test.tsx | 9 ++++----- .../src/components/createComponentRef.tsx | 16 ++++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx index e20a35ff84..32912ca3d5 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx @@ -18,7 +18,7 @@ import { createComponentRef } from './createComponentRef'; describe('createComponentRef', () => { it('can be created and read', () => { - const ref = createComponentRef({ id: 'foo' }); + const ref = createComponentRef({ id: 'foo', mode: 'sync' }); expect(ref.id).toBe('foo'); expect(String(ref)).toBe('ComponentRef{id=foo}'); }); @@ -45,7 +45,6 @@ describe('createComponentRef', () => { defaultComponent: async ({ bar }) => , }); - // todo: why do we have two errors here? // @ts-expect-error - this should be an error as mode is async createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', @@ -53,11 +52,9 @@ describe('createComponentRef', () => { defaultComponent: ({ bar }) => , }); - // todo: why does this not work? createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - // @ts-expect-error - this should be an error as default mode is async - defaultComponent: ({ bar }) => , + mode: 'sync', }); expect(Test).toBeDefined(); @@ -66,11 +63,13 @@ describe('createComponentRef', () => { it('should allow transformings props', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', + mode: 'sync', transformProps: props => ({ foo: props.bar }), }); createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', + mode: 'sync', // @ts-expect-error - this should be an error as foo is not a string transformProps: props => ({ foo: 1 }), }); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index 9f36e7f21e..37095ca055 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -18,26 +18,30 @@ export type ComponentRef< TInnerComponentProps, TExternalComponentProps, - TMode extends 'sync' | 'async' = 'async', + TMode extends 'sync' | 'async', > = { id: string; mode: TMode; transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; defaultComponent?: TMode extends 'async' ? (props: TExternalComponentProps) => Promise - : (props: TExternalComponentProps) => JSX.Element; + : TMode extends 'sync' + ? (props: TExternalComponentProps) => JSX.Element + : never; }; export interface ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, - TMode extends 'sync' | 'async' = 'async', + TMode extends 'sync' | 'async', > { id: string; - mode?: TMode; + mode: TMode; defaultComponent?: TMode extends 'async' ? (props: TExternalComponentProps) => Promise - : (props: TExternalComponentProps) => JSX.Element; + : TMode extends 'sync' + ? (props: TExternalComponentProps) => JSX.Element + : never; transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; } @@ -82,7 +86,7 @@ export function createComponentRef< TMode >, ): ComponentRef { - const { id, mode = 'async', defaultComponent, transformProps } = options; + const { id, mode, defaultComponent, transformProps } = options; return { id, From 6f8a093ac6094cbfce28e467eb9f2c34d20eb46b Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 28 Jul 2025 16:40:34 +0200 Subject: [PATCH 03/29] feat: added opaque type helper Signed-off-by: benjdlambert --- .../src/wiring/InternalComponentRef.ts | 41 +++++++++++++++++++ .../frontend-internal/src/wiring/index.ts | 1 + 2 files changed, 42 insertions(+) create mode 100644 packages/frontend-internal/src/wiring/InternalComponentRef.ts diff --git a/packages/frontend-internal/src/wiring/InternalComponentRef.ts b/packages/frontend-internal/src/wiring/InternalComponentRef.ts new file mode 100644 index 0000000000..93cdf38b1a --- /dev/null +++ b/packages/frontend-internal/src/wiring/InternalComponentRef.ts @@ -0,0 +1,41 @@ +/* + * 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 { ComponentRef } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export const OpaqueComponentRef = OpaqueType.create<{ + public: ComponentRef; + versions: { + readonly version: 'v1'; + readonly options: + | { + mode: 'sync'; + transformProps?: (props: object) => object; + defaultComponent?: (props: object) => JSX.Element | null; + } + | { + mode: 'async'; + transformProps?: (props: object) => object; + defaultComponent?: () => Promise< + (props: object) => JSX.Element | null + >; + }; + }; +}>({ + versions: ['v1'], + type: '@backstage/ComponentRef', +}); diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index 6a7cf35e27..bc2314f25e 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 { OpaqueComponentRef } from './InternalComponentRef'; export { OpaqueExtensionDefinition } from './InternalExtensionDefinition'; export { OpaqueFrontendPlugin } from './InternalFrontendPlugin'; From e09f27a9a86a4ac60ac2e972fe7f19c6eb1623e9 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 28 Jul 2025 16:41:00 +0200 Subject: [PATCH 04/29] feat: implementing makeComponentFromRef Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../src/components/coreComponentRefs.ts | 3 + .../components/createComponentRef.test.tsx | 19 ++- .../src/components/createComponentRef.tsx | 66 +++++---- .../components/makeComponentFromRef.test.tsx | 128 ++++++++++++++++++ .../src/components/makeComponentFromRef.tsx | 62 +++++++++ 5 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx create mode 100644 packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts index 64412b8710..0a4aa10cf6 100644 --- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts +++ b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts @@ -23,16 +23,19 @@ import { createComponentRef } from './createComponentRef'; const coreProgressComponentRef = createComponentRef({ id: 'core.components.progress', + mode: 'sync', }); const coreNotFoundErrorPageComponentRef = createComponentRef({ id: 'core.components.notFoundErrorPage', + mode: 'sync', }); const coreErrorBoundaryFallbackComponentRef = createComponentRef({ id: 'core.components.errorBoundaryFallback', + mode: 'sync', }); /** @public */ diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx index 32912ca3d5..751daa0c3f 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx @@ -29,26 +29,29 @@ describe('createComponentRef', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'sync', - defaultComponent: ({ bar }) => , + defaultComponent: ({ foo }) => , }); createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'async', - defaultComponent: async () => , + defaultComponent: + async () => + ({ foo }) => + , }); - // @ts-expect-error - this should be an error as mode is sync createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'sync', - defaultComponent: async ({ bar }) => , + // @ts-expect-error - this should be an error as mode is sync + defaultComponent: async ({ foo }) => , }); - // @ts-expect-error - this should be an error as mode is async createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'async', + // @ts-expect-error - this should be an error as mode is async defaultComponent: ({ bar }) => , }); @@ -74,6 +77,12 @@ describe('createComponentRef', () => { transformProps: props => ({ foo: 1 }), }); + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + mode: 'sync', + transformProps: props => ({ foo: props.bar }), + }); + expect(true).toBe(true); }); }); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index 37095ca055..3912b6bdad 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -14,33 +14,30 @@ * limitations under the License. */ +import { OpaqueComponentRef } from '@internal/frontend'; + /** @public */ export type ComponentRef< - TInnerComponentProps, - TExternalComponentProps, - TMode extends 'sync' | 'async', + TInnerComponentProps = {}, + TExternalComponentProps = TInnerComponentProps, > = { id: string; - mode: TMode; - transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; - defaultComponent?: TMode extends 'async' - ? (props: TExternalComponentProps) => Promise - : TMode extends 'sync' - ? (props: TExternalComponentProps) => JSX.Element - : never; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/ComponentRef'; }; export interface ComponentRefOptions< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object, TMode extends 'sync' | 'async', > { id: string; mode: TMode; defaultComponent?: TMode extends 'async' - ? (props: TExternalComponentProps) => Promise + ? () => Promise<(props: TInnerComponentProps) => JSX.Element> : TMode extends 'sync' - ? (props: TExternalComponentProps) => JSX.Element + ? (props: TInnerComponentProps) => JSX.Element : never; transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; } @@ -50,51 +47,52 @@ export interface ComponentRefOptions< * @public */ export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, 'sync' >, -): ComponentRef; +): ComponentRef; /** * Creates a new component ref that is asynchronous. * @public */ export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, 'async' >, -): ComponentRef; +): ComponentRef; export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, - TMode extends 'sync' | 'async', + TInnerComponentProps extends object, + TExternalComponentProps extends object, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, - TMode + 'async' | 'sync' >, -): ComponentRef { - const { id, mode, defaultComponent, transformProps } = options; - - return { - id, - mode, - defaultComponent, - transformProps, +): ComponentRef { + return OpaqueComponentRef.createInstance('v1', { + id: options.id, + TProps: {} as TInnerComponentProps, + TExternalProps: {} as TExternalComponentProps, toString() { - return `ComponentRef{id=${id}}`; + return `ComponentRef{id=${options.id}}`; }, - } as ComponentRef; + options: { + mode: options.mode, + defaultComponent: options.defaultComponent, + transformProps: options.transformProps, + }, + }); } diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx new file mode 100644 index 0000000000..ffaa9d5ccf --- /dev/null +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { createComponentRef } from './createComponentRef'; +import { makeComponentFromRef } from './makeComponentFromRef'; + +describe('makeComponentFromRef', () => { + describe('sync', () => { + it('should create a component from a ref for sync component', () => { + const ref = createComponentRef({ + id: 'random', + mode: 'sync', + defaultComponent: (props: { name: string }) => { + return
{props.name}
; + }, + }); + + 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({ + id: 'random', + mode: 'sync', + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + expect(screen.getByTestId('random')).toBeInTheDocument(); + }); + + it('should map props from external to internal', () => { + const ref = createComponentRef({ + id: 'random', + mode: 'sync', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + defaultComponent: props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + expect(screen.getByTestId('test')).toHaveTextContent('TEST'); + }); + }); + + describe('async', () => { + it('should create a component from a ref for async component', async () => { + const ref = createComponentRef({ + id: 'random', + mode: 'async', + defaultComponent: 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({ + id: 'random', + mode: 'async', + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); + }); + + it('should map props from external to internal', async () => { + const ref = createComponentRef({ + id: 'random', + mode: 'async', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + defaultComponent: async () => props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'TEST', + ); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx new file mode 100644 index 0000000000..ef3ca0ac4b --- /dev/null +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -0,0 +1,62 @@ +/* + * 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 { Progress } from '@backstage/core-components'; + +export function makeComponentFromRef< + InternalComponentProps extends object, + ExternalComponentProps extends object, +>({ + ref, +}: { + ref: ComponentRef; +}): (props: ExternalComponentProps) => JSX.Element { + const { options } = OpaqueComponentRef.toInternal(ref); + const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( +
+ ); + + const ComponentRefImpl = (props: ExternalComponentProps) => { + const innerProps = options.transformProps?.(props) ?? props; + + if (options.mode === 'sync') { + const DefaultImplementation = + options.defaultComponent ?? FallbackComponent; + + return ; + } + + const DefaultImplementation = lazy( + () => + options.defaultComponent?.().then(c => { + return { default: c }; + }) ?? + Promise.resolve({ + default: FallbackComponent, + }), + ); + + return ( + + + + ); + }; + + return ComponentRefImpl; +} From 58a85aa4d44c8b90eff94640332f9d591f345e77 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 29 Jul 2025 13:57:11 +0200 Subject: [PATCH 05/29] chore: cleanup object types Signed-off-by: benjdlambert --- .../src/components/createComponentRef.tsx | 26 +++++++++---------- .../src/components/makeComponentFromRef.tsx | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index 3912b6bdad..ebf707fcca 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -18,8 +18,8 @@ import { OpaqueComponentRef } from '@internal/frontend'; /** @public */ export type ComponentRef< - TInnerComponentProps = {}, - TExternalComponentProps = TInnerComponentProps, + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, > = { id: string; TProps: TInnerComponentProps; @@ -28,8 +28,8 @@ export type ComponentRef< }; export interface ComponentRefOptions< - TInnerComponentProps extends object, - TExternalComponentProps extends object, + TInnerComponentProps extends {}, + TExternalComponentProps extends {}, TMode extends 'sync' | 'async', > { id: string; @@ -47,8 +47,8 @@ export interface ComponentRefOptions< * @public */ export function createComponentRef< - TInnerComponentProps extends object, - TExternalComponentProps extends object = TInnerComponentProps, + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, @@ -62,8 +62,8 @@ export function createComponentRef< * @public */ export function createComponentRef< - TInnerComponentProps extends object, - TExternalComponentProps extends object = TInnerComponentProps, + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, @@ -73,8 +73,8 @@ export function createComponentRef< ): ComponentRef; export function createComponentRef< - TInnerComponentProps extends object, - TExternalComponentProps extends object, + TInnerComponentProps extends {}, + TExternalComponentProps extends {}, >( options: ComponentRefOptions< TInnerComponentProps, @@ -84,8 +84,8 @@ export function createComponentRef< ): ComponentRef { return OpaqueComponentRef.createInstance('v1', { id: options.id, - TProps: {} as TInnerComponentProps, - TExternalProps: {} as TExternalComponentProps, + TProps: null as unknown as TInnerComponentProps, + TExternalProps: null as unknown as TExternalComponentProps, toString() { return `ComponentRef{id=${options.id}}`; }, @@ -93,6 +93,6 @@ export function createComponentRef< mode: options.mode, defaultComponent: options.defaultComponent, transformProps: options.transformProps, - }, + } as (typeof OpaqueComponentRef.TInternal)['options'], }); } diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx index ef3ca0ac4b..3bf7cf69ad 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -16,11 +16,10 @@ import { lazy, Suspense } from 'react'; import { ComponentRef } from './createComponentRef'; import { OpaqueComponentRef } from '@internal/frontend'; -import { Progress } from '@backstage/core-components'; export function makeComponentFromRef< - InternalComponentProps extends object, - ExternalComponentProps extends object, + InternalComponentProps extends {}, + ExternalComponentProps extends {}, >({ ref, }: { @@ -52,6 +51,7 @@ export function makeComponentFromRef< ); return ( + // todo: is this necessary? can we remove this? From 976e2f5999f7e05b923ecdd2fab91c353cf3f57a Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 31 Jul 2025 16:11:21 +0200 Subject: [PATCH 06/29] feat: some work towrads component ref blueprint Signed-off-by: benjdlambert --- .../DefaultComponentsApi.test.tsx | 6 +- .../ComponentImplementationBlueprint.test.tsx | 42 +++++++++++++ .../ComponentImplementationBlueprint.ts | 61 +++++++++++++++++++ .../src/components/createComponentRef.tsx | 28 +++++---- .../src/wiring/createExtensionBlueprint.ts | 1 - 5 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx create mode 100644 packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts 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..b2ab5d5aef 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 @@ -18,9 +18,9 @@ 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' }); +const testRefA = createComponentRef({ id: 'test.a', mode: 'sync' }); +const testRefB1 = createComponentRef({ id: 'test.b', mode: 'sync' }); +const testRefB2 = createComponentRef({ id: 'test.b', mode: 'sync' }); describe('DefaultComponentsApi', () => { it('should provide components', () => { diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx new file mode 100644 index 0000000000..b0095e4350 --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { createComponentRef } from '../components'; +import { ComponentImplementationBlueprint } from './ComponentImplementationBlueprint'; + +describe('ComponentImplementationBlueprint', () => { + it('should allow defining a component override for sync component ref', () => { + const componentRef = createComponentRef({ + id: 'test.component', + mode: 'sync', + defaultComponent: (props: { hello: string }) =>
{props.hello}
, + }); + + const extension = ComponentImplementationBlueprint.make({ + params: define => + define({ + ref: componentRef, + component: props => { + // @ts-expect-error + const t: number = props.hello; + + return
Override {props.hello}
; + }, + }), + }); + + expect(extension).toBeDefined(); + }); +}); diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts new file mode 100644 index 0000000000..d903d63f5e --- /dev/null +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts @@ -0,0 +1,61 @@ +/* + * 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 { ComponentRef } from '../components'; +import { + createExtensionBlueprint, + createExtensionBlueprintParams, + createExtensionDataRef, +} from '../wiring'; + +export const componentDataRef = createExtensionDataRef<{ + ref: ComponentRef; + component: + | ((props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + type: 'sync' | 'async'; +}>().with({ id: 'core.component.component' }); + +export const ComponentImplementationBlueprint = createExtensionBlueprint({ + kind: 'component', + attachTo: { id: 'api:app/components', input: 'components' }, + output: [componentDataRef], + dataRefs: { + component: componentDataRef, + }, + defineParams>(params: { + ref: Ref; + component: Ref extends ComponentRef< + infer IInnerComponentProps, + any, + infer IMode + > + ? IMode extends 'sync' + ? (props: IInnerComponentProps) => JSX.Element + : IMode extends 'async' + ? () => Promise<(props: IInnerComponentProps) => JSX.Element> + : never + : never; + }) { + return createExtensionBlueprintParams(params); + }, + *factory(params) { + yield componentDataRef({ + ref: params.ref, + component: params.component, + type: params.ref.mode, + }); + }, +}); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index ebf707fcca..f0a1e36371 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -20,27 +20,27 @@ import { OpaqueComponentRef } from '@internal/frontend'; export type ComponentRef< TInnerComponentProps extends {} = {}, TExternalComponentProps extends {} = TInnerComponentProps, + TMode extends 'sync' | 'async' = 'sync' | 'async', > = { id: string; TProps: TInnerComponentProps; TExternalProps: TExternalComponentProps; + TMode: TMode; $$type: '@backstage/ComponentRef'; }; -export interface ComponentRefOptions< +export type ComponentRefOptions< TInnerComponentProps extends {}, - TExternalComponentProps extends {}, - TMode extends 'sync' | 'async', -> { + TExternalComponentProps extends {} = TInnerComponentProps, +> = { id: string; - mode: TMode; - defaultComponent?: TMode extends 'async' - ? () => Promise<(props: TInnerComponentProps) => JSX.Element> + componentAsync: TMode extends 'async' + ? () => Promise<(props: TInnerComponentProps) => JSX.Element | null> : TMode extends 'sync' - ? (props: TInnerComponentProps) => JSX.Element + ? (props: TInnerComponentProps) => JSX.Element | null : never; transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; -} +}; /** * Creates a new component ref that is synchronous. @@ -55,7 +55,7 @@ export function createComponentRef< TExternalComponentProps, 'sync' >, -): ComponentRef; +): ComponentRef; /** * Creates a new component ref that is asynchronous. @@ -70,22 +70,24 @@ export function createComponentRef< TExternalComponentProps, 'async' >, -): ComponentRef; +): ComponentRef; export function createComponentRef< TInnerComponentProps extends {}, - TExternalComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + TMode extends 'sync' | 'async' = 'sync' | 'async', >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, - 'async' | 'sync' + TMode >, ): ComponentRef { return OpaqueComponentRef.createInstance('v1', { id: options.id, TProps: null as unknown as TInnerComponentProps, TExternalProps: null as unknown as TExternalComponentProps, + TMode: null as unknown as TMode, toString() { return `ComponentRef{id=${options.id}}`; }, 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']; From aab7bc21cd38662fe499234b6088e377a3c5109b Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 4 Aug 2025 16:23:35 +0200 Subject: [PATCH 07/29] chore: enforce loader format for component refs Signed-off-by: benjdlambert --- .../src/wiring/InternalComponentRef.ts | 19 +++---- .../ComponentImplementationBlueprint.test.tsx | 5 +- .../ComponentImplementationBlueprint.ts | 22 +++----- .../components/createComponentRef.test.tsx | 34 +++---------- .../src/components/createComponentRef.tsx | 51 ++----------------- .../components/makeComponentFromRef.test.tsx | 20 +++----- .../src/components/makeComponentFromRef.tsx | 35 ++++++------- 7 files changed, 49 insertions(+), 137 deletions(-) diff --git a/packages/frontend-internal/src/wiring/InternalComponentRef.ts b/packages/frontend-internal/src/wiring/InternalComponentRef.ts index 93cdf38b1a..00d4a253d5 100644 --- a/packages/frontend-internal/src/wiring/InternalComponentRef.ts +++ b/packages/frontend-internal/src/wiring/InternalComponentRef.ts @@ -21,19 +21,12 @@ export const OpaqueComponentRef = OpaqueType.create<{ public: ComponentRef; versions: { readonly version: 'v1'; - readonly options: - | { - mode: 'sync'; - transformProps?: (props: object) => object; - defaultComponent?: (props: object) => JSX.Element | null; - } - | { - mode: 'async'; - transformProps?: (props: object) => object; - defaultComponent?: () => Promise< - (props: object) => JSX.Element | null - >; - }; + readonly options: { + transformProps?: (props: object) => object; + loader?: + | (() => (props: object) => JSX.Element | null) + | (() => Promise<(props: object) => JSX.Element | null>); + }; }; }>({ versions: ['v1'], diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx index b0095e4350..0943659fed 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx @@ -20,15 +20,14 @@ describe('ComponentImplementationBlueprint', () => { it('should allow defining a component override for sync component ref', () => { const componentRef = createComponentRef({ id: 'test.component', - mode: 'sync', - defaultComponent: (props: { hello: string }) =>
{props.hello}
, + loader: () => (props: { hello: string }) =>
{props.hello}
, }); const extension = ComponentImplementationBlueprint.make({ params: define => define({ ref: componentRef, - component: props => { + loader: () => props => { // @ts-expect-error const t: number = props.hello; diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts index d903d63f5e..0386c90f5e 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts @@ -22,10 +22,9 @@ import { export const componentDataRef = createExtensionDataRef<{ ref: ComponentRef; - component: - | ((props: {}) => JSX.Element | null) + loader: + | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); - type: 'sync' | 'async'; }>().with({ id: 'core.component.component' }); export const ComponentImplementationBlueprint = createExtensionBlueprint({ @@ -37,16 +36,10 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({ }, defineParams>(params: { ref: Ref; - component: Ref extends ComponentRef< - infer IInnerComponentProps, - any, - infer IMode - > - ? IMode extends 'sync' - ? (props: IInnerComponentProps) => JSX.Element - : IMode extends 'async' - ? () => Promise<(props: IInnerComponentProps) => JSX.Element> - : never + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) : never; }) { return createExtensionBlueprintParams(params); @@ -54,8 +47,7 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({ *factory(params) { yield componentDataRef({ ref: params.ref, - component: params.component, - type: params.ref.mode, + loader: params.loader, }); }, }); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx index 751daa0c3f..b1bcd66c17 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx @@ -18,7 +18,7 @@ import { createComponentRef } from './createComponentRef'; describe('createComponentRef', () => { it('can be created and read', () => { - const ref = createComponentRef({ id: 'foo', mode: 'sync' }); + const ref = createComponentRef({ id: 'foo' }); expect(ref.id).toBe('foo'); expect(String(ref)).toBe('ComponentRef{id=foo}'); }); @@ -28,14 +28,15 @@ describe('createComponentRef', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - mode: 'sync', - defaultComponent: ({ foo }) => , + loader: + () => + ({ foo }) => + , }); createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - mode: 'async', - defaultComponent: + loader: async () => ({ foo }) => , @@ -43,21 +44,6 @@ describe('createComponentRef', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - mode: 'sync', - // @ts-expect-error - this should be an error as mode is sync - defaultComponent: async ({ foo }) => , - }); - - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - mode: 'async', - // @ts-expect-error - this should be an error as mode is async - defaultComponent: ({ bar }) => , - }); - - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - mode: 'sync', }); expect(Test).toBeDefined(); @@ -66,23 +52,15 @@ describe('createComponentRef', () => { it('should allow transformings props', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - mode: 'sync', transformProps: props => ({ foo: props.bar }), }); createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', - mode: 'sync', // @ts-expect-error - this should be an error as foo is not a string transformProps: props => ({ foo: 1 }), }); - createComponentRef<{ foo: string }, { bar: string }>({ - id: 'foo', - mode: 'sync', - transformProps: props => ({ foo: props.bar }), - }); - expect(true).toBe(true); }); }); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index f0a1e36371..7c4f027fa6 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -20,12 +20,10 @@ import { OpaqueComponentRef } from '@internal/frontend'; export type ComponentRef< TInnerComponentProps extends {} = {}, TExternalComponentProps extends {} = TInnerComponentProps, - TMode extends 'sync' | 'async' = 'sync' | 'async', > = { id: string; TProps: TInnerComponentProps; TExternalProps: TExternalComponentProps; - TMode: TMode; $$type: '@backstage/ComponentRef'; }; @@ -34,66 +32,27 @@ export type ComponentRefOptions< TExternalComponentProps extends {} = TInnerComponentProps, > = { id: string; - componentAsync: TMode extends 'async' - ? () => Promise<(props: TInnerComponentProps) => JSX.Element | null> - : TMode extends 'sync' - ? (props: TInnerComponentProps) => JSX.Element | null - : never; + loader?: + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; }; -/** - * Creates a new component ref that is synchronous. - * @public - */ export function createComponentRef< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( - options: ComponentRefOptions< - TInnerComponentProps, - TExternalComponentProps, - 'sync' - >, -): ComponentRef; - -/** - * Creates a new component ref that is asynchronous. - * @public - */ -export function createComponentRef< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, ->( - options: ComponentRefOptions< - TInnerComponentProps, - TExternalComponentProps, - 'async' - >, -): ComponentRef; - -export function createComponentRef< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, - TMode extends 'sync' | 'async' = 'sync' | 'async', ->( - options: ComponentRefOptions< - TInnerComponentProps, - TExternalComponentProps, - TMode - >, + options: ComponentRefOptions, ): ComponentRef { return OpaqueComponentRef.createInstance('v1', { id: options.id, TProps: null as unknown as TInnerComponentProps, TExternalProps: null as unknown as TExternalComponentProps, - TMode: null as unknown as TMode, toString() { return `ComponentRef{id=${options.id}}`; }, options: { - mode: options.mode, - defaultComponent: options.defaultComponent, + loader: options.loader, transformProps: options.transformProps, } as (typeof OpaqueComponentRef.TInternal)['options'], }); diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx index ffaa9d5ccf..a35ede3e09 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx @@ -22,15 +22,16 @@ describe('makeComponentFromRef', () => { it('should create a component from a ref for sync component', () => { const ref = createComponentRef({ id: 'random', - mode: 'sync', - defaultComponent: (props: { name: string }) => { + loader: () => (props: { name: string }) => { return
{props.name}
; }, + transformProps: (props: { id: string }) => ({ + name: props.id, + }), }); const Component = makeComponentFromRef({ ref }); - - render(); + render(); expect(screen.getByTestId('test')).toHaveTextContent('test'); }); @@ -38,7 +39,6 @@ describe('makeComponentFromRef', () => { it('should render a fallback when theres no default implementation provided', () => { const ref = createComponentRef({ id: 'random', - mode: 'sync', }); const Component = makeComponentFromRef({ ref }); @@ -51,11 +51,10 @@ describe('makeComponentFromRef', () => { it('should map props from external to internal', () => { const ref = createComponentRef({ id: 'random', - mode: 'sync', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), }), - defaultComponent: props => { + loader: () => props => { // @ts-expect-error as uppercase is types as a string const test: number = props.uppercase; @@ -75,8 +74,7 @@ describe('makeComponentFromRef', () => { it('should create a component from a ref for async component', async () => { const ref = createComponentRef({ id: 'random', - mode: 'async', - defaultComponent: async () => (props: { name: string }) => { + loader: async () => (props: { name: string }) => { return
{props.name}
; }, }); @@ -91,7 +89,6 @@ describe('makeComponentFromRef', () => { it('should render a fallback when theres no default implementation provided', async () => { const ref = createComponentRef({ id: 'random', - mode: 'async', }); const Component = makeComponentFromRef({ ref }); @@ -104,11 +101,10 @@ describe('makeComponentFromRef', () => { it('should map props from external to internal', async () => { const ref = createComponentRef({ id: 'random', - mode: 'async', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), }), - defaultComponent: async () => props => { + loader: async () => props => { // @ts-expect-error as uppercase is types as a string const test: number = props.uppercase; diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx index 3bf7cf69ad..2f0155fbec 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -33,29 +33,24 @@ export function makeComponentFromRef< const ComponentRefImpl = (props: ExternalComponentProps) => { const innerProps = options.transformProps?.(props) ?? props; - if (options.mode === 'sync') { - const DefaultImplementation = - options.defaultComponent ?? FallbackComponent; + const ComponentOrPromise = options.loader?.() ?? FallbackComponent; - return ; + if ('then' in ComponentOrPromise) { + const DefaultImplementation = lazy(() => + ComponentOrPromise.then(c => { + return { default: c }; + }), + ); + + return ( + // todo: is this necessary? can we remove this? + + + + ); } - const DefaultImplementation = lazy( - () => - options.defaultComponent?.().then(c => { - return { default: c }; - }) ?? - Promise.resolve({ - default: FallbackComponent, - }), - ); - - return ( - // todo: is this necessary? can we remove this? - - - - ); + return ; }; return ComponentRefImpl; From eeade86c096aeebfdeb0ab56b08081b19faa29d4 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 4 Aug 2025 16:50:53 +0200 Subject: [PATCH 08/29] chore: more tests Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../ComponentImplementationBlueprint.test.tsx | 117 +++++++++++++++++- .../src/components/makeComponentFromRef.tsx | 2 + 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx index 0943659fed..cd109a6562 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.test.tsx @@ -13,11 +13,15 @@ * See the License for the specific language governing permissions and * 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 { PageBlueprint } from './PageBlueprint'; +import { waitFor, screen } from '@testing-library/react'; describe('ComponentImplementationBlueprint', () => { - it('should allow defining a component override for sync component ref', () => { + it('should allow defining a component override for a component ref', () => { const componentRef = createComponentRef({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, @@ -38,4 +42,115 @@ describe('ComponentImplementationBlueprint', () => { expect(extension).toBeDefined(); }); + + it('should render default component refs in the app', async () => { + const testComponentRef = createComponentRef({ + id: 'test.component', + loader: () => (props: { hello: string }) =>
{props.hello}
, + }); + + const TestComponent = makeComponentFromRef({ ref: testComponentRef }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + // todo(blam): there's a bug that this path cannot be `/`? + defaultPath: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + }); + + it('should render a component ref without a default implementation', async () => { + const testComponentRef = createComponentRef({ + id: 'test.component', + }); + + const TestComponent = makeComponentFromRef({ ref: testComponentRef }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + defaultPath: '/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 testComponentRef = createComponentRef({ + id: 'test.component', + loader: async () => (props: { hello: string }) => +
{props.hello}
, + }); + + const TestComponent = makeComponentFromRef({ ref: testComponentRef }); + + renderInTestApp(
, { + extensions: [ + PageBlueprint.make({ + params: define => + define({ + // todo(blam): there's a bug that this path cannot be `/`? + defaultPath: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); + }); + + it('should allow overriding a component ref with the blueprint', async () => { + const testComponentRef = createComponentRef({ + id: 'test.component', + loader: () => (props: { hello: string }) =>
{props.hello}
, + }); + + const TestComponent = makeComponentFromRef({ ref: testComponentRef }); + + const extension = ComponentImplementationBlueprint.make({ + params: define => + define({ + ref: testComponentRef, + loader: () => props =>
Override {props.hello}
, + }), + }); + + renderInTestApp(
, { + extensions: [ + extension, + PageBlueprint.make({ + params: define => + define({ + defaultPath: '/test', + loader: async () => , + }), + }), + ], + initialRouteEntries: ['/test'], + }); + + await waitFor(() => + expect(screen.getByText('Override test!')).toBeInTheDocument(), + ); + }); }); diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx index 2f0155fbec..c9c58a6ed6 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -16,6 +16,7 @@ import { lazy, Suspense } from 'react'; import { ComponentRef } from './createComponentRef'; import { OpaqueComponentRef } from '@internal/frontend'; +import { componentsApiRef, useApi } from '../apis'; export function makeComponentFromRef< InternalComponentProps extends {}, @@ -31,6 +32,7 @@ export function makeComponentFromRef< ); const ComponentRefImpl = (props: ExternalComponentProps) => { + const api = useApi(componentsApiRef); const innerProps = options.transformProps?.(props) ?? props; const ComponentOrPromise = options.loader?.() ?? FallbackComponent; From e3c320a514eb12595d498526d1048de030a1f9c3 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 4 Aug 2025 16:58:39 +0200 Subject: [PATCH 09/29] chore: removing mode from other fixtures Signed-off-by: benjdlambert --- .../ComponentsApi/DefaultComponentsApi.test.tsx | 6 +++--- .../src/blueprints/ComponentImplementationBlueprint.ts | 8 ++++---- .../src/components/coreComponentRefs.ts | 3 --- .../src/components/makeComponentFromRef.tsx | 2 ++ 4 files changed, 9 insertions(+), 10 deletions(-) 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 b2ab5d5aef..7d06d55a92 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 @@ -18,9 +18,9 @@ import { createComponentRef } from '@backstage/frontend-plugin-api'; import { DefaultComponentsApi } from './DefaultComponentsApi'; import { render, screen } from '@testing-library/react'; -const testRefA = createComponentRef({ id: 'test.a', mode: 'sync' }); -const testRefB1 = createComponentRef({ id: 'test.b', mode: 'sync' }); -const testRefB2 = createComponentRef({ id: 'test.b', mode: 'sync' }); +const testRefA = createComponentRef({ id: 'test.a' }); +const testRefB1 = createComponentRef({ id: 'test.b' }); +const testRefB2 = createComponentRef({ id: 'test.b' }); describe('DefaultComponentsApi', () => { it('should provide components', () => { diff --git a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts index 0386c90f5e..5d2ddea25e 100644 --- a/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/ComponentImplementationBlueprint.ts @@ -44,10 +44,10 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({ }) { return createExtensionBlueprintParams(params); }, - *factory(params) { - yield componentDataRef({ + factory: params => [ + componentDataRef({ ref: params.ref, loader: params.loader, - }); - }, + }), + ], }); diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts index 0a4aa10cf6..64412b8710 100644 --- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts +++ b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts @@ -23,19 +23,16 @@ import { createComponentRef } from './createComponentRef'; const coreProgressComponentRef = createComponentRef({ id: 'core.components.progress', - mode: 'sync', }); const coreNotFoundErrorPageComponentRef = createComponentRef({ id: 'core.components.notFoundErrorPage', - mode: 'sync', }); const coreErrorBoundaryFallbackComponentRef = createComponentRef({ id: 'core.components.errorBoundaryFallback', - mode: 'sync', }); /** @public */ diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx index c9c58a6ed6..7953549fbc 100644 --- a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -32,6 +32,8 @@ export function makeComponentFromRef< ); 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; From 71510fb81244bad75fd7c9b438cbdd4420de6342 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 5 Aug 2025 14:27:04 +0200 Subject: [PATCH 10/29] feat: big refactor of componentRefs again to move away from makeComponentRefs Signed-off-by: benjdlambert --- .../examples/notFoundErrorPageExtension.tsx | 15 +-- .../compatWrapper/BackwardsCompatProvider.tsx | 22 ++-- .../src/convertLegacyAppOptions.tsx | 41 ++++-- .../src/components/Progress/Progress.tsx | 10 +- .../DefaultComponentsApi.test.tsx | 19 +-- .../ComponentsApi/DefaultComponentsApi.ts | 24 ++-- .../src/wiring/InternalComponentRef.ts | 10 +- .../src/apis/definitions/ComponentsApi.ts | 25 ++-- ...x => AdaptableComponentBlueprint.test.tsx} | 33 ++--- ...rint.ts => AdaptableComponentBlueprint.ts} | 8 +- .../src/blueprints/index.ts | 1 + .../src/components/ExtensionBoundary.tsx | 9 +- .../src/components/coreComponentRefs.ts | 43 ------- ....tsx => createAdaptableComponent.test.tsx} | 80 ++++++++---- .../components/createAdaptableComponent.tsx | 119 ++++++++++++++++++ .../components/createComponentRef.test.tsx | 66 ---------- .../src/components/createComponentRef.tsx | 59 --------- .../src/components/index.ts | 6 +- .../src/components/makeComponentFromRef.tsx | 61 --------- .../extensions/createComponentExtension.tsx | 72 ----------- .../src/extensions/index.ts | 17 --- packages/frontend-plugin-api/src/index.ts | 1 - plugins/app/src/extensions/AppRoutes.tsx | 7 +- plugins/app/src/extensions/ComponentsApi.tsx | 6 +- plugins/app/src/extensions/components.tsx | 40 ++---- plugins/app/src/extensions/index.ts | 7 +- plugins/app/src/index.ts | 2 + plugins/app/src/plugin.ts | 6 - 28 files changed, 310 insertions(+), 499 deletions(-) rename packages/frontend-plugin-api/src/blueprints/{ComponentImplementationBlueprint.test.tsx => AdaptableComponentBlueprint.test.tsx} (79%) rename packages/frontend-plugin-api/src/blueprints/{ComponentImplementationBlueprint.ts => AdaptableComponentBlueprint.ts} (85%) delete mode 100644 packages/frontend-plugin-api/src/components/coreComponentRefs.ts rename packages/frontend-plugin-api/src/components/{makeComponentFromRef.test.tsx => createAdaptableComponent.test.tsx} (65%) create mode 100644 packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx delete mode 100644 packages/frontend-plugin-api/src/components/createComponentRef.test.tsx delete mode 100644 packages/frontend-plugin-api/src/components/createComponentRef.tsx delete mode 100644 packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx delete mode 100644 packages/frontend-plugin-api/src/extensions/createComponentExtension.tsx delete mode 100644 packages/frontend-plugin-api/src/extensions/index.ts 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, From 3cbba41ad4b943e6ec0a6750d6093a91212b48ae Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 5 Aug 2025 14:27:47 +0200 Subject: [PATCH 11/29] chore: fix deps but will undo Signed-off-by: benjdlambert --- packages/core-compat-api/package.json | 2 ++ yarn.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 9856abbebb..1569dda585 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -31,8 +31,10 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-app": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21" diff --git a/yarn.lock b/yarn.lock index 22a4100216..3dbfb91e4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4117,10 +4117,12 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" + "@backstage/plugin-app": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/test-utils": "workspace:^" From 9c7ec669cf9d4de89353a032fb974d91bd689552 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 09:38:32 +0200 Subject: [PATCH 12/29] chore: moved implementations to app pliugin Signed-off-by: benjdlambert --- .../examples/notFoundErrorPageExtension.tsx | 6 ++- .../compatWrapper/ForwardsCompatProvider.tsx | 1 - .../src/convertLegacyAppOptions.tsx | 7 ++-- .../src/components/Progress/Progress.tsx | 10 +---- .../AdaptableComponentBlueprint.test.tsx | 8 ++-- .../components/DefaultAdaptableComponents.ts | 28 +++++++++++++ .../src/components/index.ts | 1 + plugins/app/src/extensions/AppRoutes.tsx | 2 +- plugins/app/src/extensions/components.tsx | 39 +++++++++++++++---- plugins/app/src/extensions/index.ts | 3 +- plugins/app/src/index.ts | 2 - plugins/app/src/plugin.ts | 6 +++ 12 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts diff --git a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx index 67746f6c68..64572ccb62 100644 --- a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx +++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx @@ -14,11 +14,13 @@ * limitations under the License. */ -import { AdaptableComponentBlueprint } from '@backstage/frontend-plugin-api'; +import { + AdaptableComponentBlueprint, + 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'; -import { NotFoundErrorPage } from '@backstage/plugin-app'; export function CustomNotFoundErrorPage() { return ( diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index 925d30306c..aa0b7bf556 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -35,7 +35,6 @@ import { RouteResolutionApi, SubRouteRef, componentsApiRef, - coreComponentRefs, iconsApiRef, routeResolutionApiRef, } from '@backstage/frontend-plugin-api'; diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx index 4895f3234c..215185853a 100644 --- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -38,11 +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'; + NotFoundErrorPage as AdaptableNotFoundErrorPage, + Progress as AdaptableProgress, +} from '@backstage/frontend-plugin-api'; function componentCompatWrapper( Component: ComponentType, @@ -167,6 +167,7 @@ export function convertLegacyAppOptions( }), ); } + if (NotFoundErrorPage) { extensions.push( AdaptableComponentBlueprint.make({ diff --git a/packages/core-components/src/components/Progress/Progress.tsx b/packages/core-components/src/components/Progress/Progress.tsx index 27706cb7d1..b7176a5825 100644 --- a/packages/core-components/src/components/Progress/Progress.tsx +++ b/packages/core-components/src/components/Progress/Progress.tsx @@ -21,10 +21,7 @@ import LinearProgress, { import { useTheme } from '@material-ui/core/styles'; import { PropsWithChildren, useEffect, useState } from 'react'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { createAdaptableComponent } from '../../../../frontend-plugin-api/src/components/createAdaptableComponent'; - -function ProgressComponent(props: PropsWithChildren) { +export function Progress(props: PropsWithChildren) { const theme = useTheme(); const [isVisible, setIsVisible] = useState(false); @@ -42,8 +39,3 @@ function ProgressComponent(props: PropsWithChildren) { ); } - -export const Progress = createAdaptableComponent({ - id: 'core.components.progress', - loader: () => ProgressComponent, -}); diff --git a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx index e52076896f..3eb73ca7a9 100644 --- a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx @@ -54,7 +54,7 @@ describe('AdaptableComponentBlueprint', () => { params: define => define({ // todo(blam): there's a bug that this path cannot be `/`? - defaultPath: '/test', + path: '/test', loader: async () => , }), }), @@ -75,7 +75,7 @@ describe('AdaptableComponentBlueprint', () => { PageBlueprint.make({ params: define => define({ - defaultPath: '/test', + path: '/test', loader: async () => , }), }), @@ -101,7 +101,7 @@ describe('AdaptableComponentBlueprint', () => { params: define => define({ // todo(blam): there's a bug that this path cannot be `/`? - defaultPath: '/test', + path: '/test', loader: async () => , }), }), @@ -132,7 +132,7 @@ describe('AdaptableComponentBlueprint', () => { PageBlueprint.make({ params: define => define({ - defaultPath: '/test', + path: '/test', loader: async () => , }), }), diff --git a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts new file mode 100644 index 0000000000..db8b29446c --- /dev/null +++ b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts @@ -0,0 +1,28 @@ +/* + * 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 { createAdaptableComponent } from '@backstage/frontend-plugin-api'; + +export const Progress = createAdaptableComponent({ + id: 'core.components.progress', +}); + +export const NotFoundErrorPage = createAdaptableComponent({ + id: 'core.components.notFoundErrorPage', +}); + +export const ErrorBoundary = createAdaptableComponent({ + id: 'core.components.errorBoundary', +}); diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 9c50ba0c43..0c8c69c355 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -23,3 +23,4 @@ export { type ComponentRef, } from './createAdaptableComponent'; export { useAppNode } from './AppNodeProvider'; +export * from './DefaultAdaptableComponents'; diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 6291f24949..5cc3c3f4b8 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -18,9 +18,9 @@ import { createExtension, coreExtensionData, createExtensionInput, + NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; import { useRoutes } from 'react-router-dom'; -import { NotFoundErrorPage } from './components'; export const AppRoutes = createExtension({ name: 'routes', diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 6dd46bcd6e..91cf8c7b63 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,15 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + NotFoundErrorPage as AdaptableNotFoundErrorPage, + Progress as AdaptableProgress, + ErrorBoundary as AdaptableErrorBoundary, + AdaptableComponentBlueprint, +} from '@backstage/frontend-plugin-api'; -import { createAdaptableComponent } from '@backstage/frontend-plugin-api'; +import { Progress as ProgressComponent } from '@backstage/core-components'; -export const NotFoundErrorPage = createAdaptableComponent({ - id: 'core.components.notFoundErrorPage', - // todo: implementation +export const Progress = AdaptableComponentBlueprint.make({ + name: 'core.components.progress', + params: define => + define({ + component: AdaptableProgress, + loader: () => ProgressComponent, + }), }); -export const ErrorBoundary = createAdaptableComponent({ - id: 'core.components.errorBoundaryFallback', - // todo: implementation +export const NotFoundErrorPage = AdaptableComponentBlueprint.make({ + name: 'core.components.notFoundErrorPage', + params: define => + define({ + component: AdaptableNotFoundErrorPage, + loader: () => NotFoundErrorPageComponent, + }), +}); + +export const ErrorBoundary = AdaptableComponentBlueprint.make({ + name: 'core.components.errorBoundary', + params: define => + define({ + component: AdaptableErrorBoundary, + loader: () => ErrorBoundaryComponent, + }), }); diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 487494601b..4bf77b1bb5 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -26,9 +26,8 @@ export { FeatureFlagsApi } from './FeatureFlagsApi'; export { TranslationsApi } from './TranslationsApi'; export { DefaultSignInPage } from './DefaultSignInPage'; export { dialogDisplayAppRootElement } from './DialogDisplay'; -// todo: move these -export { ErrorBoundary, NotFoundErrorPage } from './components'; export { oauthRequestDialogAppRootElement, alertDisplayAppRootElement, } from './elements'; +export { Progress, NotFoundErrorPage, ErrorBoundary } from './components'; diff --git a/plugins/app/src/index.ts b/plugins/app/src/index.ts index d2614dcf66..24f65df618 100644 --- a/plugins/app/src/index.ts +++ b/plugins/app/src/index.ts @@ -15,5 +15,3 @@ */ 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 09596a7385..6a965216cf 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -33,6 +33,9 @@ import { alertDisplayAppRootElement, DefaultSignInPage, dialogDisplayAppRootElement, + Progress, + NotFoundErrorPage, + ErrorBoundary, } from './extensions'; import { apis } from './defaultApis'; @@ -59,5 +62,8 @@ export const appPlugin = createFrontendPlugin({ oauthRequestDialogAppRootElement, alertDisplayAppRootElement, dialogDisplayAppRootElement, + Progress, + NotFoundErrorPage, + ErrorBoundary, ], }); From 233a4eddf16f662678c4593440a8835d8d654fdc Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 10:17:16 +0200 Subject: [PATCH 13/29] chore: updating api reports Signed-off-by: benjdlambert --- .../compatWrapper/BackwardsCompatProvider.tsx | 6 +- .../compatWrapper/ForwardsCompatProvider.tsx | 31 +++- .../src/compatWrapper/compatWrapper.test.tsx | 12 +- .../blueprints/AdaptableComponentBlueprint.ts | 5 + .../components/DefaultAdaptableComponents.ts | 32 +++-- .../components/createAdaptableComponent.tsx | 17 ++- .../src/components/index.ts | 1 + plugins/app/report.api.md | 136 +++++++++++++++++- plugins/app/src/extensions/components.tsx | 22 ++- yarn.lock | 33 +++-- 10 files changed, 254 insertions(+), 41 deletions(-) diff --git a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx index 2f0cd451e2..2652aa69cf 100644 --- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx @@ -27,6 +27,9 @@ import { iconsApiRef, useApi, routeResolutionApiRef, + ErrorBoundary, + NotFoundErrorPage, + Progress, } from '@backstage/frontend-plugin-api'; import { AppComponents, @@ -42,9 +45,6 @@ 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', diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index aa0b7bf556..ce2ce29bdb 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -37,6 +37,9 @@ import { componentsApiRef, iconsApiRef, routeResolutionApiRef, + Progress, + NotFoundErrorPage, + ErrorBoundary, } from '@backstage/frontend-plugin-api'; import { ComponentType, useMemo } from 'react'; import { ReactNode } from 'react'; @@ -66,14 +69,28 @@ class CompatComponentsApi implements ComponentsApi { this.#ErrorBoundaryFallback = ErrorBoundaryFallback; } - getComponent(ref: ComponentRef): ComponentType { + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: ComponentRef, + ): + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) + | undefined { 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 ErrorBoundary.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}'`, diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index f1418e545f..785a1769f8 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -16,13 +16,15 @@ import { componentsApiRef, - coreComponentRefs, coreExtensionData, createExtension, iconsApiRef, useRouteRef as useNewRouteRef, createRouteRef as createNewRouteRef, useApi, + NotFoundErrorPage, + ErrorBoundary, + 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, + errorBoundary: ErrorBoundary.ref, + }; + function Component() { const components = useApi(componentsApiRef); const icons = useApi(iconsApiRef); return (
components:{' '} - {Object.entries(coreComponentRefs) + {Object.entries(defaultComponentRefs) .map( ([name, ref]) => `${name}=${Boolean(components.getComponent(ref))}`, diff --git a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts index 457a04327c..a53f4d4a42 100644 --- a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts @@ -27,6 +27,11 @@ export const componentDataRef = createExtensionDataRef<{ | (() => Promise<(props: {}) => JSX.Element | null>); }>().with({ id: 'core.component.component' }); +/** + * Blueprint for creating adaptable components from a componentRef and a loader + * + * @public + */ export const AdaptableComponentBlueprint = createExtensionBlueprint({ kind: 'component', attachTo: { id: 'api:app/components', input: 'components' }, diff --git a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts index db8b29446c..2dde24eb1b 100644 --- a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts @@ -13,16 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createAdaptableComponent } from '@backstage/frontend-plugin-api'; +import { + CoreErrorBoundaryFallbackProps, + CoreNotFoundErrorPageProps, + CoreProgressProps, + createAdaptableComponent, +} from '@backstage/frontend-plugin-api'; -export const Progress = createAdaptableComponent({ +/** + * @public + */ +export const Progress = createAdaptableComponent({ id: 'core.components.progress', }); -export const NotFoundErrorPage = createAdaptableComponent({ - id: 'core.components.notFoundErrorPage', -}); +/** + * @public + */ +export const NotFoundErrorPage = + createAdaptableComponent({ + id: 'core.components.notFoundErrorPage', + }); -export const ErrorBoundary = createAdaptableComponent({ - id: 'core.components.errorBoundary', -}); +/** + * @public + */ +export const ErrorBoundary = + createAdaptableComponent({ + id: 'core.components.errorBoundary', + }); diff --git a/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx b/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx index 3a454d5c7e..3d9b4149c2 100644 --- a/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx @@ -29,7 +29,12 @@ export type ComponentRef< $$type: '@backstage/ComponentRef'; }; -export type ComponentRefOptions< +/** + * Options for creating an AdaptableComponent. + * + * @public + */ +export type CreateAdaptableComponentOptions< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, > = { @@ -90,11 +95,19 @@ function makeComponentFromRef< return ComponentRefImpl; } +/** + * Creates a AdaptableComponent that can be used to render the component, optionally overriden by the app. + * + * @public + */ export function createAdaptableComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( - options: ComponentRefOptions, + options: CreateAdaptableComponentOptions< + TInnerComponentProps, + TExternalComponentProps + >, ): ((props: TExternalComponentProps) => JSX.Element) & { ref: ComponentRef; } { diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 0c8c69c355..f7897dfc1a 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -20,6 +20,7 @@ export { } from './ExtensionBoundary'; export { createAdaptableComponent, + type CreateAdaptableComponentOptions, type ComponentRef, } from './createAdaptableComponent'; export { useAppNode } from './AppNodeProvider'; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index f6632550c2..74b8927b44 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -324,7 +324,9 @@ const appPlugin: FrontendPlugin< ConfigurableExtensionDataRef< { ref: ComponentRef; - impl: ComponentType; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); }, 'core.component.component', {} @@ -726,6 +728,138 @@ const appPlugin: FrontendPlugin< element: JSX.Element; }; }>; + 'component:app/core.components.errorBoundary': ExtensionDefinition<{ + kind: 'component'; + name: 'core.components.errorBoundary'; + config: {}; + configInput: {}; + output: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (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: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (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: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + inputs: {}; + params: >(params: { + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise< + (props: IInnerComponentProps) => JSX.Element | null + >) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (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/components.tsx b/plugins/app/src/extensions/components.tsx index 91cf8c7b63..25a7a046cc 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -20,7 +20,12 @@ import { AdaptableComponentBlueprint, } from '@backstage/frontend-plugin-api'; -import { Progress as ProgressComponent } from '@backstage/core-components'; +import { + ErrorPage, + ErrorPanel, + Progress as ProgressComponent, +} from '@backstage/core-components'; +import Button from '@material-ui/core/Button'; export const Progress = AdaptableComponentBlueprint.make({ name: 'core.components.progress', @@ -36,7 +41,8 @@ export const NotFoundErrorPage = AdaptableComponentBlueprint.make({ params: define => define({ component: AdaptableNotFoundErrorPage, - loader: () => NotFoundErrorPageComponent, + loader: () => () => + , }), }); @@ -45,6 +51,16 @@ export const ErrorBoundary = AdaptableComponentBlueprint.make({ params: define => define({ component: AdaptableErrorBoundary, - loader: () => ErrorBoundaryComponent, + loader: () => props => { + const { plugin, error, resetError } = props; + const title = `Error in ${plugin?.id}`; + return ( + + + + ); + }, }), }); diff --git a/yarn.lock b/yarn.lock index 3dbfb91e4b..934fbc5bcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32295,12 +32295,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 @@ -34907,7 +34907,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 @@ -45108,14 +45108,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 @@ -50789,16 +50791,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 From b737934088d8b9828792ecf7ed34e8aa1be1b514 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 10:24:58 +0200 Subject: [PATCH 14/29] chore: fixing packageds Signed-off-by: benjdlambert --- packages/core-compat-api/package.json | 2 -- yarn.lock | 2 -- 2 files changed, 4 deletions(-) diff --git a/packages/core-compat-api/package.json b/packages/core-compat-api/package.json index 1569dda585..9856abbebb 100644 --- a/packages/core-compat-api/package.json +++ b/packages/core-compat-api/package.json @@ -31,10 +31,8 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/plugin-app": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/version-bridge": "workspace:^", "lodash": "^4.17.21" diff --git a/yarn.lock b/yarn.lock index 934fbc5bcb..f329407da3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4117,12 +4117,10 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" - "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" - "@backstage/plugin-app": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/test-utils": "workspace:^" From a70b6862e7d6d0043b2e97400ad29c41d48ef980 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 10:31:28 +0200 Subject: [PATCH 15/29] chore: updating API refs Signed-off-by: benjdlambert --- packages/frontend-plugin-api/report.api.md | 196 +++++++++++++-------- plugins/app/report.api.md | 6 +- 2 files changed, 128 insertions(+), 74 deletions(-) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index cc2f87112a..1e9bc0fff6 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -12,6 +12,7 @@ import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-pl import { ApiFactory } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; +import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api/src/apis/system/types'; import { ApiRefConfig } from '@backstage/core-plugin-api'; import { AppTheme } from '@backstage/core-plugin-api'; import { AppThemeApi } from '@backstage/core-plugin-api'; @@ -24,10 +25,14 @@ import { BackstageIdentityResponse } from '@backstage/core-plugin-api'; import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api'; +import { ComponentRef as ComponentRef_2 } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigApi } from '@backstage/core-plugin-api'; import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; +import { CoreErrorBoundaryFallbackProps as CoreErrorBoundaryFallbackProps_2 } from '@backstage/frontend-plugin-api'; +import { CoreNotFoundErrorPageProps as CoreNotFoundErrorPageProps_2 } from '@backstage/frontend-plugin-api'; +import { CoreProgressProps as CoreProgressProps_2 } from '@backstage/frontend-plugin-api'; import { createApiFactory } from '@backstage/core-plugin-api'; import { createApiRef } from '@backstage/core-plugin-api'; import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; @@ -94,6 +99,59 @@ import { vmwareCloudAuthApiRef } from '@backstage/core-plugin-api'; import { withApis } from '@backstage/core-plugin-api'; import { z } from 'zod'; +// @public +export const AdaptableComponentBlueprint: ExtensionBlueprint<{ + kind: 'component'; + params: >(params: { + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }>; + output: ExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + }; +}>; + export { AlertApi }; export { alertApiRef }; @@ -351,19 +409,32 @@ export { bitbucketAuthApiRef }; export { bitbucketServerAuthApiRef }; // @public (undocumented) -export type ComponentRef = { +export type ComponentRef< + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { id: string; - T: T; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/ComponentRef'; }; // @public export interface ComponentsApi { // (undocumented) - getComponent(ref: ComponentRef): ComponentType; + getComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: ComponentRef, + ): + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) + | undefined; } // @public -export const componentsApiRef: ApiRef; +export const componentsApiRef: ApiRef_2; export { ConfigApi }; @@ -389,13 +460,6 @@ export interface ConfigurableExtensionDataRef< >; } -// @public (undocumented) -export const coreComponentRefs: { - progress: ComponentRef; - notFoundErrorPage: ComponentRef; - errorBoundaryFallback: ComponentRef; -}; - // @public (undocumented) export type CoreErrorBoundaryFallbackProps = { plugin?: FrontendPlugin; @@ -426,65 +490,35 @@ export type CoreNotFoundErrorPageProps = { // @public (undocumented) export type CoreProgressProps = {}; +// @public +export function createAdaptableComponent< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +>( + options: CreateAdaptableComponentOptions< + TInnerComponentProps, + TExternalComponentProps + >, +): ((props: TExternalComponentProps) => JSX.Element) & { + ref: ComponentRef; +}; + +// @public +export type CreateAdaptableComponentOptions< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + loader?: + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); + transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; +}; + 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, @@ -922,6 +956,16 @@ export { ErrorApiErrorContext }; export { errorApiRef }; +// @public (undocumented) +export const ErrorBoundary: (( + props: CoreErrorBoundaryFallbackProps_2, +) => JSX.Element) & { + ref: ComponentRef_2< + CoreErrorBoundaryFallbackProps_2, + CoreErrorBoundaryFallbackProps_2 + >; +}; + // @public (undocumented) export interface Extension { // (undocumented) @@ -1577,6 +1621,16 @@ export const NavItemBlueprint: ExtensionBlueprint<{ }; }>; +// @public (undocumented) +export const NotFoundErrorPage: (( + props: CoreNotFoundErrorPageProps_2, +) => JSX.Element) & { + ref: ComponentRef_2< + CoreNotFoundErrorPageProps_2, + CoreNotFoundErrorPageProps_2 + >; +}; + export { OAuthApi }; export { OAuthRequestApi }; @@ -1661,6 +1715,11 @@ export { ProfileInfo }; export { ProfileInfoApi }; +// @public (undocumented) +export const Progress: ((props: CoreProgressProps_2) => JSX.Element) & { + ref: ComponentRef_2; +}; + // @public export type ResolvedExtensionInput< TExtensionInput extends ExtensionInput, @@ -1929,11 +1988,6 @@ export { useApiHolder }; // @public export function useAppNode(): AppNode | undefined; -// @public -export function useComponentRef( - ref: ComponentRef, -): ComponentType; - // @public export function useRouteRef( routeRef: diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 74b8927b44..6b9a4d3a54 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -733,7 +733,7 @@ const appPlugin: FrontendPlugin< name: 'core.components.errorBoundary'; config: {}; configInput: {}; - output: ConfigurableExtensionDataRef< + output: ExtensionDataRef< { ref: ComponentRef; loader: @@ -777,7 +777,7 @@ const appPlugin: FrontendPlugin< name: 'core.components.notFoundErrorPage'; config: {}; configInput: {}; - output: ConfigurableExtensionDataRef< + output: ExtensionDataRef< { ref: ComponentRef; loader: @@ -821,7 +821,7 @@ const appPlugin: FrontendPlugin< name: 'core.components.progress'; config: {}; configInput: {}; - output: ConfigurableExtensionDataRef< + output: ExtensionDataRef< { ref: ComponentRef; loader: From 5775f10d14047cf63cd2d03755a0ece3bdce1622 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 10:40:52 +0200 Subject: [PATCH 16/29] chore: cleanup todos Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../src/convertLegacyAppOptions.tsx | 9 +++----- packages/frontend-plugin-api/report.api.md | 23 +++++++------------ .../components/DefaultAdaptableComponents.ts | 5 ++-- .../src/components/ExtensionBoundary.tsx | 6 ++--- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx index 215185853a..b007c938c2 100644 --- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -27,6 +27,9 @@ import { RouterBlueprint, SignInPageBlueprint, ThemeBlueprint, + ErrorBoundary as AdaptableErrorBoundary, + NotFoundErrorPage as AdaptableNotFoundErrorPage, + Progress as AdaptableProgress, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -38,11 +41,6 @@ import { } from '@backstage/core-plugin-api'; import { toLegacyPlugin } from './compatWrapper/BackwardsCompatProvider'; import { compatWrapper } from './compatWrapper'; -import { - ErrorBoundary as AdaptableErrorBoundary, - NotFoundErrorPage as AdaptableNotFoundErrorPage, - Progress as AdaptableProgress, -} from '@backstage/frontend-plugin-api'; function componentCompatWrapper( Component: ComponentType, @@ -197,7 +195,6 @@ export function convertLegacyAppOptions( define({ component: AdaptableErrorBoundary, loader: () => - // todo: types + props lols componentCompatWrapper(WrappedErrorBoundaryFallback), }), }), diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 1e9bc0fff6..c9d0ff5542 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -25,14 +25,10 @@ import { BackstageIdentityResponse } from '@backstage/core-plugin-api'; import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api'; -import { ComponentRef as ComponentRef_2 } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigApi } from '@backstage/core-plugin-api'; import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api'; -import { CoreErrorBoundaryFallbackProps as CoreErrorBoundaryFallbackProps_2 } from '@backstage/frontend-plugin-api'; -import { CoreNotFoundErrorPageProps as CoreNotFoundErrorPageProps_2 } from '@backstage/frontend-plugin-api'; -import { CoreProgressProps as CoreProgressProps_2 } from '@backstage/frontend-plugin-api'; import { createApiFactory } from '@backstage/core-plugin-api'; import { createApiRef } from '@backstage/core-plugin-api'; import { createTranslationMessages } from '@backstage/core-plugin-api/alpha'; @@ -958,11 +954,11 @@ export { errorApiRef }; // @public (undocumented) export const ErrorBoundary: (( - props: CoreErrorBoundaryFallbackProps_2, + props: CoreErrorBoundaryFallbackProps, ) => JSX.Element) & { - ref: ComponentRef_2< - CoreErrorBoundaryFallbackProps_2, - CoreErrorBoundaryFallbackProps_2 + ref: ComponentRef< + CoreErrorBoundaryFallbackProps, + CoreErrorBoundaryFallbackProps >; }; @@ -1623,12 +1619,9 @@ export const NavItemBlueprint: ExtensionBlueprint<{ // @public (undocumented) export const NotFoundErrorPage: (( - props: CoreNotFoundErrorPageProps_2, + props: CoreNotFoundErrorPageProps, ) => JSX.Element) & { - ref: ComponentRef_2< - CoreNotFoundErrorPageProps_2, - CoreNotFoundErrorPageProps_2 - >; + ref: ComponentRef; }; export { OAuthApi }; @@ -1716,8 +1709,8 @@ export { ProfileInfo }; export { ProfileInfoApi }; // @public (undocumented) -export const Progress: ((props: CoreProgressProps_2) => JSX.Element) & { - ref: ComponentRef_2; +export const Progress: ((props: CoreProgressProps) => JSX.Element) & { + ref: ComponentRef; }; // @public diff --git a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts index 2dde24eb1b..5319c9017b 100644 --- a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { CoreErrorBoundaryFallbackProps, CoreNotFoundErrorPageProps, CoreProgressProps, - createAdaptableComponent, -} from '@backstage/frontend-plugin-api'; +} from '../types'; +import { createAdaptableComponent } from './createAdaptableComponent'; /** * @public diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index e4e7e05b0f..0296d70b64 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -29,6 +29,7 @@ import { AppNode } from '../apis'; import { Progress } from '@backstage/core-components'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; +import { ErrorBoundary as ErrorBoundaryComponent } from './DefaultAdaptableComponents'; type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; @@ -67,9 +68,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { const plugin = node.spec.plugin; - // todo: fallback - const fallback = () =>
Fallback
; - // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extensionId: node.spec.id, @@ -79,7 +77,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { return ( }> - + {children} From 414d1be0559f4a3bc2f877dad80e3eecf572ebac Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 11:32:38 +0200 Subject: [PATCH 17/29] chore: fixing tests Signed-off-by: benjdlambert --- .../src/compatWrapper/compatWrapper.test.tsx | 2 +- .../ComponentsApi/DefaultComponentsApi.test.tsx | 8 +++++--- packages/frontend-defaults/src/createApp.test.tsx | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index 785a1769f8..86af1f4212 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -126,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, errorBoundary=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/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx index e7491308b8..9602fc382f 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 @@ -17,6 +17,7 @@ import { createAdaptableComponent } from '@backstage/frontend-plugin-api'; import { DefaultComponentsApi } from './DefaultComponentsApi'; import { render, screen } from '@testing-library/react'; +import { getJSDocOverrideTagNoCache } from 'typescript'; const { ref: testRefA } = createAdaptableComponent({ id: 'test.a' }); const { ref: testRefB1 } = createAdaptableComponent({ id: 'test.b' }); @@ -39,19 +40,20 @@ describe('DefaultComponentsApi', () => { }); it('should key extension refs by ID', () => { + const mockLoader = jest.fn(() =>
test.b
); const api = DefaultComponentsApi.fromComponents([ { ref: testRefB1, - loader: () => () =>
test.b
, + loader: () => mockLoader, }, ]); - const ComponentB1 = api.getComponent(testRefB1)?.() as () => JSX.Element; const ComponentB2 = api.getComponent(testRefB2)?.() as () => JSX.Element; + const ComponentB1 = api.getComponent(testRefB1)?.() as () => JSX.Element; expect(ComponentB1).toBe(ComponentB2); - render(); + render(); expect(screen.getByText('test.b')).toBeInTheDocument(); }); diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 89ac0a5b39..cc9a7ba3cd 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -370,7 +370,7 @@ describe('createApp', () => { components [ - + ] From af1d30064ca0123741d53f65750690c02e52f148 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 11:35:38 +0200 Subject: [PATCH 18/29] chore: fixing docs Signed-off-by: benjdlambert --- .../building-plugins/03-common-extension-blueprints.md | 4 ++-- .../ComponentsApi/DefaultComponentsApi.test.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) 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..11114047cb 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -17,9 +17,9 @@ 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) +### AdaptableComponent - [Reference](../../reference/frontend-plugin-api.adaptablecomponentblueprint.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. +Adaptable Components are extensions that are used to override implementation of components in the app and plugins. ### NavItem - [Reference](../../reference/frontend-plugin-api.navitemblueprint.md) 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 9602fc382f..a5b219ec27 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 @@ -17,7 +17,6 @@ import { createAdaptableComponent } from '@backstage/frontend-plugin-api'; import { DefaultComponentsApi } from './DefaultComponentsApi'; import { render, screen } from '@testing-library/react'; -import { getJSDocOverrideTagNoCache } from 'typescript'; const { ref: testRefA } = createAdaptableComponent({ id: 'test.a' }); const { ref: testRefB1 } = createAdaptableComponent({ id: 'test.b' }); From 3a34fb4cc7eb7368b9e4d0a7b8014d25937eef9e Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 11:50:20 +0200 Subject: [PATCH 19/29] chore: fixing types Signed-off-by: benjdlambert --- packages/frontend-plugin-api/report.api.md | 3 +-- .../frontend-plugin-api/src/apis/definitions/ComponentsApi.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c9d0ff5542..053c078728 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -12,7 +12,6 @@ import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-pl import { ApiFactory } from '@backstage/core-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; -import { ApiRef as ApiRef_2 } from '@backstage/core-plugin-api/src/apis/system/types'; import { ApiRefConfig } from '@backstage/core-plugin-api'; import { AppTheme } from '@backstage/core-plugin-api'; import { AppThemeApi } from '@backstage/core-plugin-api'; @@ -430,7 +429,7 @@ export interface ComponentsApi { } // @public -export const componentsApiRef: ApiRef_2; +export const componentsApiRef: ApiRef; export { ConfigApi }; diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts index 0a46e1d018..a36ca8adc6 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts @@ -15,7 +15,7 @@ */ import { ComponentRef } from '../../components'; -import { createApiRef } from '../system/ApiRef'; +import { createApiRef } from '@backstage/core-plugin-api'; /** * API for looking up components based on component refs. From d996f05ae57d60d6d56806425040288d6c588c4c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 13:36:21 +0200 Subject: [PATCH 20/29] chore: `adaptable` -> `swappable` Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../config/vocabularies/Backstage/accept.txt | 1 + .../03-common-extension-blueprints.md | 8 +- .../examples/notFoundErrorPageExtension.tsx | 6 +- .../src/convertLegacyAppOptions.tsx | 20 +-- .../DefaultComponentsApi.test.tsx | 8 +- .../ComponentsApi/DefaultComponentsApi.ts | 4 +- packages/frontend-plugin-api/report.api.md | 156 +++++++++--------- ...x => SwappableComponentBlueprint.test.tsx} | 20 +-- ...rint.ts => SwappableComponentBlueprint.ts} | 4 +- .../src/blueprints/index.ts | 2 +- ...nents.ts => DefaultSwappableComponents.ts} | 8 +- .../src/components/ExtensionBoundary.tsx | 2 +- ....tsx => createSwappableComponent.test.tsx} | 28 ++-- ...onent.tsx => createSwappableComponent.tsx} | 10 +- .../src/components/index.ts | 8 +- plugins/app/src/extensions/ComponentsApi.tsx | 6 +- plugins/app/src/extensions/components.tsx | 20 +-- 17 files changed, 156 insertions(+), 155 deletions(-) rename packages/frontend-plugin-api/src/blueprints/{AdaptableComponentBlueprint.test.tsx => SwappableComponentBlueprint.test.tsx} (87%) rename packages/frontend-plugin-api/src/blueprints/{AdaptableComponentBlueprint.ts => SwappableComponentBlueprint.ts} (93%) rename packages/frontend-plugin-api/src/components/{DefaultAdaptableComponents.ts => DefaultSwappableComponents.ts} (80%) rename packages/frontend-plugin-api/src/components/{createAdaptableComponent.test.tsx => createSwappableComponent.test.tsx} (83%) rename packages/frontend-plugin-api/src/components/{createAdaptableComponent.tsx => createSwappableComponent.tsx} (93%) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 86c9be9b98..aa6de7c545 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 11114047cb..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. -### AdaptableComponent - [Reference](../../reference/frontend-plugin-api.adaptablecomponentblueprint.md) - -Adaptable Components are extensions that are used to override implementation of components in the app and plugins. - ### 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 64572ccb62..7f690f90cb 100644 --- a/packages/app-next/src/examples/notFoundErrorPageExtension.tsx +++ b/packages/app-next/src/examples/notFoundErrorPageExtension.tsx @@ -15,14 +15,14 @@ */ import { - AdaptableComponentBlueprint, + 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 ( define({ diff --git a/packages/core-compat-api/src/convertLegacyAppOptions.tsx b/packages/core-compat-api/src/convertLegacyAppOptions.tsx index b007c938c2..7615027a96 100644 --- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -16,7 +16,7 @@ import { ComponentType } from 'react'; import { - AdaptableComponentBlueprint, + SwappableComponentBlueprint, ApiBlueprint, CoreErrorBoundaryFallbackProps, createExtension, @@ -27,9 +27,9 @@ import { RouterBlueprint, SignInPageBlueprint, ThemeBlueprint, - ErrorBoundary as AdaptableErrorBoundary, - NotFoundErrorPage as AdaptableNotFoundErrorPage, - Progress as AdaptableProgress, + ErrorBoundary as SwappableErrorBoundary, + NotFoundErrorPage as SwappableNotFoundErrorPage, + Progress as SwappableProgress, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -156,10 +156,10 @@ export function convertLegacyAppOptions( } if (Progress) { extensions.push( - AdaptableComponentBlueprint.make({ + SwappableComponentBlueprint.make({ params: define => define({ - component: AdaptableProgress, + component: SwappableProgress, loader: () => componentCompatWrapper(Progress), }), }), @@ -168,10 +168,10 @@ export function convertLegacyAppOptions( if (NotFoundErrorPage) { extensions.push( - AdaptableComponentBlueprint.make({ + SwappableComponentBlueprint.make({ params: define => define({ - component: AdaptableNotFoundErrorPage, + component: SwappableNotFoundErrorPage, loader: () => componentCompatWrapper(NotFoundErrorPage), }), }), @@ -190,10 +190,10 @@ export function convertLegacyAppOptions( ); extensions.push( - AdaptableComponentBlueprint.make({ + SwappableComponentBlueprint.make({ params: define => define({ - component: AdaptableErrorBoundary, + component: SwappableErrorBoundary, loader: () => componentCompatWrapper(WrappedErrorBoundaryFallback), }), 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 a5b219ec27..7c550103f3 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,13 +14,13 @@ * limitations under the License. */ -import { createAdaptableComponent } from '@backstage/frontend-plugin-api'; +import { createSwappableComponent } from '@backstage/frontend-plugin-api'; import { DefaultComponentsApi } from './DefaultComponentsApi'; import { render, screen } from '@testing-library/react'; -const { ref: testRefA } = createAdaptableComponent({ id: 'test.a' }); -const { ref: testRefB1 } = createAdaptableComponent({ id: 'test.b' }); -const { ref: testRefB2 } = createAdaptableComponent({ id: 'test.b' }); +const { ref: testRefA } = createSwappableComponent({ id: 'test.a' }); +const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' }); +const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' }); describe('DefaultComponentsApi', () => { it('should provide components', () => { 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 88265955d6..6f5aac8275 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts @@ -17,7 +17,7 @@ import { ComponentRef, ComponentsApi, - AdaptableComponentBlueprint, + SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; /** @@ -34,7 +34,7 @@ export class DefaultComponentsApi implements ComponentsApi { >; static fromComponents( - components: Array, + components: Array, ) { return new DefaultComponentsApi( new Map(components.map(entry => [entry.ref.id, entry.loader])), diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 053c078728..de85709f4f 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -94,59 +94,6 @@ import { vmwareCloudAuthApiRef } from '@backstage/core-plugin-api'; import { withApis } from '@backstage/core-plugin-api'; import { z } from 'zod'; -// @public -export const AdaptableComponentBlueprint: ExtensionBlueprint<{ - kind: 'component'; - params: >(params: { - component: Ref extends ComponentRef - ? { - ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) - : never; - loader: Ref extends ComponentRef - ? - | (() => (props: IInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) - : never; - }) => ExtensionBlueprintParams<{ - component: Ref extends ComponentRef - ? { - ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) - : never; - loader: Ref extends ComponentRef - ? - | (() => (props: IInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) - : never; - }>; - output: ExtensionDataRef< - { - ref: ComponentRef; - loader: - | (() => (props: {}) => JSX.Element | null) - | (() => Promise<(props: {}) => JSX.Element | null>); - }, - 'core.component.component', - {} - >; - inputs: {}; - config: {}; - configInput: {}; - dataRefs: { - component: ConfigurableExtensionDataRef< - { - ref: ComponentRef; - loader: - | (() => (props: {}) => JSX.Element | null) - | (() => Promise<(props: {}) => JSX.Element | null>); - }, - 'core.component.component', - {} - >; - }; -}>; - export { AlertApi }; export { alertApiRef }; @@ -485,31 +432,6 @@ export type CoreNotFoundErrorPageProps = { // @public (undocumented) export type CoreProgressProps = {}; -// @public -export function createAdaptableComponent< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, ->( - options: CreateAdaptableComponentOptions< - TInnerComponentProps, - TExternalComponentProps - >, -): ((props: TExternalComponentProps) => JSX.Element) & { - ref: ComponentRef; -}; - -// @public -export type CreateAdaptableComponentOptions< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, -> = { - id: string; - loader?: - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>); - transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; -}; - export { createApiFactory }; export { createApiRef }; @@ -901,6 +823,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) & { + ref: ComponentRef; +}; + +// @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 }; @@ -1904,6 +1851,59 @@ export interface SubRouteRef< readonly T: TParams; } +// @public +export const SwappableComponentBlueprint: ExtensionBlueprint<{ + kind: 'component'; + params: >(params: { + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }) => ExtensionBlueprintParams<{ + component: Ref extends ComponentRef + ? { + ref: Ref; + } & ((props: IExternalComponentProps) => JSX.Element) + : never; + loader: Ref extends ComponentRef + ? + | (() => (props: IInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) + : never; + }>; + output: ExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + { + ref: ComponentRef; + loader: + | (() => (props: {}) => JSX.Element | null) + | (() => Promise<(props: {}) => JSX.Element | null>); + }, + 'core.component.component', + {} + >; + }; +}>; + // @public export const ThemeBlueprint: ExtensionBlueprint<{ kind: 'theme'; diff --git a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx similarity index 87% rename from packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx rename to packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx index 3eb73ca7a9..8fae754799 100644 --- a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ import { renderInTestApp } from '@backstage/frontend-test-utils'; -import { createAdaptableComponent } from '../components'; -import { AdaptableComponentBlueprint } from './AdaptableComponentBlueprint'; +import { createSwappableComponent } from '../components'; +import { SwappableComponentBlueprint } from './SwappableComponentBlueprint'; import { PageBlueprint } from './PageBlueprint'; import { waitFor, screen } from '@testing-library/react'; -describe('AdaptableComponentBlueprint', () => { +describe('SwappableComponentBlueprint', () => { it('should allow defining a component override for a component ref', () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); - const extension = AdaptableComponentBlueprint.make({ + const extension = SwappableComponentBlueprint.make({ params: define => define({ component: Component, @@ -43,7 +43,7 @@ describe('AdaptableComponentBlueprint', () => { }); it('should render default component refs in the app', async () => { - const TestComponent = createAdaptableComponent({ + const TestComponent = createSwappableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); @@ -66,7 +66,7 @@ describe('AdaptableComponentBlueprint', () => { }); it('should render a component ref without a default implementation', async () => { - const TestComponent = createAdaptableComponent({ + const TestComponent = createSwappableComponent({ id: 'test.component', }); @@ -89,7 +89,7 @@ describe('AdaptableComponentBlueprint', () => { }); it('should render a component ref with an async loader implementation', async () => { - const TestComponent = createAdaptableComponent({ + const TestComponent = createSwappableComponent({ id: 'test.component', loader: async () => (props: { hello: string }) =>
{props.hello}
, @@ -113,12 +113,12 @@ describe('AdaptableComponentBlueprint', () => { }); it('should allow overriding a component ref with the blueprint', async () => { - const TestComponent = createAdaptableComponent({ + const TestComponent = createSwappableComponent({ id: 'test.component', loader: () => (props: { hello: string }) =>
{props.hello}
, }); - const extension = AdaptableComponentBlueprint.make({ + const extension = SwappableComponentBlueprint.make({ params: define => define({ component: TestComponent, diff --git a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts similarity index 93% rename from packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts rename to packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts index a53f4d4a42..71466fc608 100644 --- a/packages/frontend-plugin-api/src/blueprints/AdaptableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -28,11 +28,11 @@ export const componentDataRef = createExtensionDataRef<{ }>().with({ id: 'core.component.component' }); /** - * Blueprint for creating adaptable components from a componentRef and a loader + * Blueprint for creating swappable components from a componentRef and a loader * * @public */ -export const AdaptableComponentBlueprint = createExtensionBlueprint({ +export const SwappableComponentBlueprint = createExtensionBlueprint({ kind: 'component', attachTo: { id: 'api:app/components', input: 'components' }, output: [componentDataRef], diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index b7164beab3..c8699b4232 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -33,4 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint'; export { SignInPageBlueprint } from './SignInPageBlueprint'; export { ThemeBlueprint } from './ThemeBlueprint'; export { TranslationBlueprint } from './TranslationBlueprint'; -export { AdaptableComponentBlueprint } from './AdaptableComponentBlueprint'; +export { SwappableComponentBlueprint } from './SwappableComponentBlueprint'; diff --git a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts similarity index 80% rename from packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts rename to packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts index 5319c9017b..1ff805ba23 100644 --- a/packages/frontend-plugin-api/src/components/DefaultAdaptableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts @@ -19,12 +19,12 @@ import { CoreNotFoundErrorPageProps, CoreProgressProps, } from '../types'; -import { createAdaptableComponent } from './createAdaptableComponent'; +import { createSwappableComponent } from './createSwappableComponent'; /** * @public */ -export const Progress = createAdaptableComponent({ +export const Progress = createSwappableComponent({ id: 'core.components.progress', }); @@ -32,7 +32,7 @@ export const Progress = createAdaptableComponent({ * @public */ export const NotFoundErrorPage = - createAdaptableComponent({ + createSwappableComponent({ id: 'core.components.notFoundErrorPage', }); @@ -40,6 +40,6 @@ export const NotFoundErrorPage = * @public */ export const ErrorBoundary = - createAdaptableComponent({ + createSwappableComponent({ id: 'core.components.errorBoundary', }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 0296d70b64..e88b26f1ef 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -29,7 +29,7 @@ import { AppNode } from '../apis'; import { Progress } from '@backstage/core-components'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; -import { ErrorBoundary as ErrorBoundaryComponent } from './DefaultAdaptableComponents'; +import { ErrorBoundary as ErrorBoundaryComponent } from './DefaultSwappableComponents'; type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; diff --git a/packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx similarity index 83% rename from packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx rename to packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx index 0712b4a5e0..64d989cdb9 100644 --- a/packages/frontend-plugin-api/src/components/createAdaptableComponent.test.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx @@ -15,11 +15,11 @@ */ import { render, screen } from '@testing-library/react'; -import { createAdaptableComponent } from './createAdaptableComponent'; +import { createSwappableComponent } from './createSwappableComponent'; -describe('createAdaptableComponent', () => { +describe('createSwappableComponent', () => { it('can be created and read', () => { - const { ref } = createAdaptableComponent({ id: 'foo' }); + const { ref } = createSwappableComponent({ id: 'foo' }); expect(ref.id).toBe('foo'); expect(String(ref)).toBe('ComponentRef{id=foo}'); }); @@ -27,7 +27,7 @@ describe('createAdaptableComponent', () => { it('should allow defining a default component implementation', () => { const Test = () =>
test
; - createAdaptableComponent<{ foo: string }, { bar: string }>({ + createSwappableComponent<{ foo: string }, { bar: string }>({ id: 'foo', loader: () => @@ -35,7 +35,7 @@ describe('createAdaptableComponent', () => { , }); - createAdaptableComponent<{ foo: string }, { bar: string }>({ + createSwappableComponent<{ foo: string }, { bar: string }>({ id: 'foo', loader: async () => @@ -43,7 +43,7 @@ describe('createAdaptableComponent', () => { , }); - createAdaptableComponent<{ foo: string }, { bar: string }>({ + createSwappableComponent<{ foo: string }, { bar: string }>({ id: 'foo', }); @@ -51,12 +51,12 @@ describe('createAdaptableComponent', () => { }); it('should allow transformings props', () => { - createAdaptableComponent<{ foo: string }, { bar: string }>({ + createSwappableComponent<{ foo: string }, { bar: string }>({ id: 'foo', transformProps: props => ({ foo: props.bar }), }); - createAdaptableComponent<{ foo: string }, { bar: string }>({ + 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 }), @@ -67,7 +67,7 @@ describe('createAdaptableComponent', () => { describe('sync', () => { it('should create a component from a ref for sync component', () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', loader: () => (props: { name: string }) => { return
{props.name}
; @@ -83,7 +83,7 @@ describe('createAdaptableComponent', () => { }); it('should render a fallback when theres no default implementation provided', () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', }); @@ -93,7 +93,7 @@ describe('createAdaptableComponent', () => { }); it('should map props from external to internal', () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), @@ -114,7 +114,7 @@ describe('createAdaptableComponent', () => { describe('async', () => { it('should create a component from a ref for async component', async () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', loader: async () => (props: { name: string }) => { return
{props.name}
; @@ -127,7 +127,7 @@ describe('createAdaptableComponent', () => { }); it('should render a fallback when theres no default implementation provided', async () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', }); @@ -137,7 +137,7 @@ describe('createAdaptableComponent', () => { }); it('should map props from external to internal', async () => { - const Component = createAdaptableComponent({ + const Component = createSwappableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ uppercase: props.name.toUpperCase(), diff --git a/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx similarity index 93% rename from packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx rename to packages/frontend-plugin-api/src/components/createSwappableComponent.tsx index 3d9b4149c2..4089c9d011 100644 --- a/packages/frontend-plugin-api/src/components/createAdaptableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -30,11 +30,11 @@ export type ComponentRef< }; /** - * Options for creating an AdaptableComponent. + * Options for creating an SwappableComponent. * * @public */ -export type CreateAdaptableComponentOptions< +export type CreateSwappableComponentOptions< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, > = { @@ -96,15 +96,15 @@ function makeComponentFromRef< } /** - * Creates a AdaptableComponent that can be used to render the component, optionally overriden by the app. + * Creates a SwappableComponent that can be used to render the component, optionally overriden by the app. * * @public */ -export function createAdaptableComponent< +export function createSwappableComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( - options: CreateAdaptableComponentOptions< + options: CreateSwappableComponentOptions< TInnerComponentProps, TExternalComponentProps >, diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index f7897dfc1a..6e28d769c9 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -19,9 +19,9 @@ export { type ExtensionBoundaryProps, } from './ExtensionBoundary'; export { - createAdaptableComponent, - type CreateAdaptableComponentOptions, + createSwappableComponent, + type CreateSwappableComponentOptions, type ComponentRef, -} from './createAdaptableComponent'; +} from './createSwappableComponent'; export { useAppNode } from './AppNodeProvider'; -export * from './DefaultAdaptableComponents'; +export * from './DefaultSwappableComponents'; diff --git a/plugins/app/src/extensions/ComponentsApi.tsx b/plugins/app/src/extensions/ComponentsApi.tsx index 1717872a22..a457e05f3e 100644 --- a/plugins/app/src/extensions/ComponentsApi.tsx +++ b/plugins/app/src/extensions/ComponentsApi.tsx @@ -15,7 +15,7 @@ */ import { - AdaptableComponentBlueprint, + SwappableComponentBlueprint, createExtensionInput, ApiBlueprint, componentsApiRef, @@ -30,7 +30,7 @@ export const ComponentsApi = ApiBlueprint.makeWithOverrides({ name: 'components', inputs: { components: createExtensionInput( - [AdaptableComponentBlueprint.dataRefs.component], + [SwappableComponentBlueprint.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(AdaptableComponentBlueprint.dataRefs.component), + i.get(SwappableComponentBlueprint.dataRefs.component), ), ), }), diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 25a7a046cc..872630a995 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ import { - NotFoundErrorPage as AdaptableNotFoundErrorPage, - Progress as AdaptableProgress, - ErrorBoundary as AdaptableErrorBoundary, - AdaptableComponentBlueprint, + NotFoundErrorPage as SwappableNotFoundErrorPage, + Progress as SwappableProgress, + ErrorBoundary as SwappableErrorBoundary, + SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; import { @@ -27,30 +27,30 @@ import { } from '@backstage/core-components'; import Button from '@material-ui/core/Button'; -export const Progress = AdaptableComponentBlueprint.make({ +export const Progress = SwappableComponentBlueprint.make({ name: 'core.components.progress', params: define => define({ - component: AdaptableProgress, + component: SwappableProgress, loader: () => ProgressComponent, }), }); -export const NotFoundErrorPage = AdaptableComponentBlueprint.make({ +export const NotFoundErrorPage = SwappableComponentBlueprint.make({ name: 'core.components.notFoundErrorPage', params: define => define({ - component: AdaptableNotFoundErrorPage, + component: SwappableNotFoundErrorPage, loader: () => () => , }), }); -export const ErrorBoundary = AdaptableComponentBlueprint.make({ +export const ErrorBoundary = SwappableComponentBlueprint.make({ name: 'core.components.errorBoundary', params: define => define({ - component: AdaptableErrorBoundary, + component: SwappableErrorBoundary, loader: () => props => { const { plugin, error, resetError } = props; const title = `Error in ${plugin?.id}`; From 4b3ba2b0488aef09857fbfa5bfc62ab5b19287af Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Wed, 6 Aug 2025 14:24:17 +0200 Subject: [PATCH 21/29] chore: make more things swoppable Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../compatWrapper/ForwardsCompatProvider.tsx | 16 +- .../src/compatWrapper/compatWrapper.test.tsx | 6 +- .../DefaultSwappableComponentsApi.test.tsx} | 19 ++- .../DefaultSwappableComponentsApi.ts} | 12 +- .../index.ts | 2 +- ...ef.ts => InternalSwappableComponentRef.ts} | 8 +- .../frontend-internal/src/wiring/index.ts | 2 +- packages/frontend-plugin-api/report.api.md | 99 ++++++------ ...onentsApi.ts => SwappableComponentsApi.ts} | 14 +- .../src/apis/definitions/index.ts | 2 +- .../blueprints/SwappableComponentBlueprint.ts | 17 +- .../components/createSwappableComponent.tsx | 33 ++-- .../src/components/index.ts | 2 +- plugins/app/report.api.md | 150 +++++++++++------- ...nentsApi.tsx => SwappableComponentsApi.ts} | 12 +- plugins/app/src/extensions/index.ts | 2 +- plugins/app/src/plugin.ts | 4 +- 17 files changed, 228 insertions(+), 172 deletions(-) rename packages/frontend-app-api/src/apis/implementations/{ComponentsApi/DefaultComponentsApi.test.tsx => SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx} (75%) rename packages/frontend-app-api/src/apis/implementations/{ComponentsApi/DefaultComponentsApi.ts => SwappableComponentsApi/DefaultSwappableComponentsApi.ts} (85%) rename packages/frontend-app-api/src/apis/implementations/{ComponentsApi => SwappableComponentsApi}/index.ts (88%) rename packages/frontend-internal/src/wiring/{InternalComponentRef.ts => InternalSwappableComponentRef.ts} (81%) rename packages/frontend-plugin-api/src/apis/definitions/{ComponentsApi.ts => SwappableComponentsApi.ts} (74%) rename plugins/app/src/extensions/{ComponentsApi.tsx => SwappableComponentsApi.ts} (78%) diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index ce2ce29bdb..099cb836bb 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -22,8 +22,8 @@ import { } from '@backstage/core-plugin-api'; import { AnyRouteRefParams, - ComponentRef, - ComponentsApi, + SwappableComponentRef, + SwappableComponentsApi, CoreErrorBoundaryFallbackProps, CoreNotFoundErrorPageProps, CoreProgressProps, @@ -34,7 +34,7 @@ import { RouteRef, RouteResolutionApi, SubRouteRef, - componentsApiRef, + swappableComponentsApiRef, iconsApiRef, routeResolutionApiRef, Progress, @@ -51,7 +51,7 @@ import { useVersionedContext } from '@backstage/version-bridge'; import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef'; import { convertLegacyRouteRef } from '../convertLegacyRouteRef'; -class CompatComponentsApi implements ComponentsApi { +class CompatComponentsApi implements SwappableComponentsApi { readonly #Progress: ComponentType; readonly #NotFoundErrorPage: ComponentType; readonly #ErrorBoundaryFallback: ComponentType; @@ -69,11 +69,11 @@ class CompatComponentsApi implements ComponentsApi { this.#ErrorBoundaryFallback = ErrorBoundaryFallback; } - getComponent< + getComponentLoader< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( - ref: ComponentRef, + ref: SwappableComponentRef, ): | (() => (props: TInnerComponentProps) => JSX.Element | null) | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) @@ -135,7 +135,7 @@ class CompatRouteResolutionApi implements RouteResolutionApi { } class ForwardsCompatApis implements ApiHolder { - readonly #componentsApi: ComponentsApi; + readonly #componentsApi: SwappableComponentsApi; readonly #iconsApi: IconsApi; readonly #routeResolutionApi: RouteResolutionApi; @@ -146,7 +146,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 86af1f4212..9accec81a6 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -15,7 +15,7 @@ */ import { - componentsApiRef, + swappableComponentsApiRef, coreExtensionData, createExtension, iconsApiRef, @@ -106,7 +106,7 @@ describe('ForwardsCompatProvider', () => { }; function Component() { - const components = useApi(componentsApiRef); + const components = useApi(swappableComponentsApiRef); const icons = useApi(iconsApiRef); return (
@@ -114,7 +114,7 @@ describe('ForwardsCompatProvider', () => { {Object.entries(defaultComponentRefs) .map( ([name, ref]) => - `${name}=${Boolean(components.getComponent(ref))}`, + `${name}=${Boolean(components.getComponentLoader(ref))}`, ) .join(', ')} {'\n'} diff --git a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx similarity index 75% rename from packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx index 7c550103f3..6afe3c2e19 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -15,7 +15,7 @@ */ import { createSwappableComponent } from '@backstage/frontend-plugin-api'; -import { DefaultComponentsApi } from './DefaultComponentsApi'; +import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi'; import { render, screen } from '@testing-library/react'; const { ref: testRefA } = createSwappableComponent({ id: 'test.a' }); @@ -24,14 +24,16 @@ const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' }); describe('DefaultComponentsApi', () => { it('should provide components', () => { - const api = DefaultComponentsApi.fromComponents([ + const api = DefaultSwappableComponentsApi.fromComponents([ { ref: testRefA, loader: () => () =>
test.a
, }, ]); - const ComponentA = api.getComponent(testRefA)?.() as () => JSX.Element; + const ComponentA = api.getComponentLoader( + testRefA, + )?.() as () => JSX.Element; render(); @@ -40,15 +42,20 @@ describe('DefaultComponentsApi', () => { it('should key extension refs by ID', () => { const mockLoader = jest.fn(() =>
test.b
); - const api = DefaultComponentsApi.fromComponents([ + const api = DefaultSwappableComponentsApi.fromComponents([ { ref: testRefB1, loader: () => mockLoader, }, ]); - const ComponentB2 = api.getComponent(testRefB2)?.() as () => JSX.Element; - const ComponentB1 = api.getComponent(testRefB1)?.() as () => JSX.Element; + const ComponentB2 = api.getComponentLoader( + testRefB2, + )?.() as () => JSX.Element; + + const ComponentB1 = api.getComponentLoader( + testRefB1, + )?.() 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/SwappableComponentsApi/DefaultSwappableComponentsApi.ts similarity index 85% rename from packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts index 6f5aac8275..885b7bdddb 100644 --- a/packages/frontend-app-api/src/apis/implementations/ComponentsApi/DefaultComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts @@ -15,8 +15,8 @@ */ import { - ComponentRef, - ComponentsApi, + SwappableComponentRef, + SwappableComponentsApi, SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; @@ -25,7 +25,7 @@ import { * * @internal */ -export class DefaultComponentsApi implements ComponentsApi { +export class DefaultSwappableComponentsApi implements SwappableComponentsApi { #components: Map< string, | (() => (props: object) => JSX.Element | null) @@ -36,7 +36,7 @@ export class DefaultComponentsApi implements ComponentsApi { static fromComponents( components: Array, ) { - return new DefaultComponentsApi( + return new DefaultSwappableComponentsApi( new Map(components.map(entry => [entry.ref.id, entry.loader])), ); } @@ -45,8 +45,8 @@ export class DefaultComponentsApi implements ComponentsApi { this.#components = components; } - getComponent( - ref: ComponentRef, + getComponentLoader( + ref: SwappableComponentRef, ): | (() => (props: object) => JSX.Element | null) | (() => Promise<(props: object) => JSX.Element | null>) 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-internal/src/wiring/InternalComponentRef.ts b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts similarity index 81% rename from packages/frontend-internal/src/wiring/InternalComponentRef.ts rename to packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts index edd1355b76..7fe27920a4 100644 --- a/packages/frontend-internal/src/wiring/InternalComponentRef.ts +++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { ComponentRef } from '@backstage/frontend-plugin-api'; +import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; import { OpaqueType } from '@internal/opaque'; -export const OpaqueComponentRef = OpaqueType.create<{ - public: ComponentRef; +export const OpaqueSwappableComponentRef = OpaqueType.create<{ + public: SwappableComponentRef; versions: { readonly version: 'v1'; readonly transformProps?: (props: object) => object; @@ -28,5 +28,5 @@ export const OpaqueComponentRef = OpaqueType.create<{ }; }>({ versions: ['v1'], - type: '@backstage/ComponentRef', + type: '@backstage/SwappableComponentRef', }); diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index bc2314f25e..b61294cb1f 100644 --- a/packages/frontend-internal/src/wiring/index.ts +++ b/packages/frontend-internal/src/wiring/index.ts @@ -15,6 +15,6 @@ */ export { createExtensionDataContainer } from './createExtensionDataContainer'; -export { OpaqueComponentRef } from './InternalComponentRef'; +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 de85709f4f..e4815fb98b 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -350,34 +350,6 @@ export { bitbucketAuthApiRef }; export { bitbucketServerAuthApiRef }; -// @public (undocumented) -export type ComponentRef< - TInnerComponentProps extends {} = {}, - TExternalComponentProps extends {} = TInnerComponentProps, -> = { - id: string; - TProps: TInnerComponentProps; - TExternalProps: TExternalComponentProps; - $$type: '@backstage/ComponentRef'; -}; - -// @public -export interface ComponentsApi { - // (undocumented) - getComponent< - TInnerComponentProps extends {}, - TExternalComponentProps extends {} = TInnerComponentProps, - >( - ref: ComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined; -} - -// @public -export const componentsApiRef: ApiRef; - export { ConfigApi }; export { configApiRef }; @@ -832,8 +804,8 @@ export function createSwappableComponent< TInnerComponentProps, TExternalComponentProps >, -): ((props: TExternalComponentProps) => JSX.Element) & { - ref: ComponentRef; +): ((props: TExternalComponentProps) => JSX.Element | null) & { + ref: SwappableComponentRef; }; // @public @@ -901,8 +873,8 @@ export { errorApiRef }; // @public (undocumented) export const ErrorBoundary: (( props: CoreErrorBoundaryFallbackProps, -) => JSX.Element) & { - ref: ComponentRef< +) => JSX.Element | null) & { + ref: SwappableComponentRef< CoreErrorBoundaryFallbackProps, CoreErrorBoundaryFallbackProps >; @@ -1566,8 +1538,11 @@ export const NavItemBlueprint: ExtensionBlueprint<{ // @public (undocumented) export const NotFoundErrorPage: (( props: CoreNotFoundErrorPageProps, -) => JSX.Element) & { - ref: ComponentRef; +) => JSX.Element | null) & { + ref: SwappableComponentRef< + CoreNotFoundErrorPageProps, + CoreNotFoundErrorPageProps + >; }; export { OAuthApi }; @@ -1655,8 +1630,8 @@ export { ProfileInfo }; export { ProfileInfoApi }; // @public (undocumented) -export const Progress: ((props: CoreProgressProps) => JSX.Element) & { - ref: ComponentRef; +export const Progress: ((props: CoreProgressProps) => JSX.Element | null) & { + ref: SwappableComponentRef; }; // @public @@ -1854,24 +1829,30 @@ export interface SubRouteRef< // @public export const SwappableComponentBlueprint: ExtensionBlueprint<{ kind: 'component'; - params: >(params: { - component: Ref extends ComponentRef + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) : never; }) => ExtensionBlueprintParams<{ - component: Ref extends ComponentRef + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) @@ -1879,7 +1860,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ }>; output: ExtensionDataRef< { - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); @@ -1893,7 +1874,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ dataRefs: { component: ConfigurableExtensionDataRef< { - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); @@ -1904,6 +1885,34 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ }; }>; +// @public (undocumented) +export type SwappableComponentRef< + TInnerComponentProps extends {} = {}, + TExternalComponentProps extends {} = TInnerComponentProps, +> = { + id: string; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/SwappableComponentRef'; +}; + +// @public +export interface SwappableComponentsApi { + // (undocumented) + getComponentLoader< + TInnerComponentProps extends {}, + TExternalComponentProps extends {} = TInnerComponentProps, + >( + ref: SwappableComponentRef, + ): + | (() => (props: TInnerComponentProps) => JSX.Element | null) + | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) + | undefined; +} + +// @public +export const swappableComponentsApiRef: ApiRef; + // @public export const ThemeBlueprint: ExtensionBlueprint<{ kind: 'theme'; diff --git a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts similarity index 74% rename from packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts rename to packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index a36ca8adc6..aeb33afca9 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ComponentRef } from '../../components'; +import { SwappableComponentRef } from '../../components'; import { createApiRef } from '@backstage/core-plugin-api'; /** @@ -22,12 +22,12 @@ import { createApiRef } from '@backstage/core-plugin-api'; * * @public */ -export interface ComponentsApi { - getComponent< +export interface SwappableComponentsApi { + getComponentLoader< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( - ref: ComponentRef, + ref: SwappableComponentRef, ): | (() => (props: TInnerComponentProps) => JSX.Element | null) | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) @@ -35,10 +35,10 @@ export interface ComponentsApi { } /** - * The `ApiRef` of {@link ComponentsApi}. + * The `ApiRef` of {@link SwappableComponentsApi}. * * @public */ -export const componentsApiRef = createApiRef({ - id: 'core.components', +export const swappableComponentsApiRef = createApiRef({ + id: 'core.swappableComponents', }); 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.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts index 71466fc608..60807f96c5 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentRef } from '../components'; +import { SwappableComponentRef } from '../components'; import { createExtensionBlueprint, createExtensionBlueprintParams, @@ -21,14 +21,14 @@ import { } from '../wiring'; export const componentDataRef = createExtensionDataRef<{ - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }>().with({ id: 'core.component.component' }); /** - * Blueprint for creating swappable components from a componentRef and a loader + * Blueprint for creating swappable components from a SwappableComponentRef and a loader * * @public */ @@ -39,11 +39,14 @@ export const SwappableComponentBlueprint = createExtensionBlueprint({ dataRefs: { component: componentDataRef, }, - defineParams>(params: { - component: Ref extends ComponentRef - ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element) + defineParams>(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > + ? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>) diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx index 4089c9d011..1218dbc8d2 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ -import { OpaqueComponentRef } from '@internal/frontend'; -import { componentsApiRef, useApi } from '../apis'; +import { OpaqueSwappableComponentRef } from '@internal/frontend'; +import { swappableComponentsApiRef, useApi } from '../apis'; import { lazy, Suspense } from 'react'; /** @public */ -export type ComponentRef< +export type SwappableComponentRef< TInnerComponentProps extends {} = {}, TExternalComponentProps extends {} = TInnerComponentProps, > = { id: string; TProps: TInnerComponentProps; TExternalProps: TExternalComponentProps; - $$type: '@backstage/ComponentRef'; + $$type: '@backstage/SwappableComponentRef'; }; /** @@ -47,7 +47,7 @@ export type CreateSwappableComponentOptions< const useComponentRefApi = () => { try { - return useApi(componentsApiRef); + return useApi(swappableComponentsApiRef); } catch (e) { return undefined; } @@ -59,9 +59,9 @@ function makeComponentFromRef< >({ ref, }: { - ref: ComponentRef; + ref: SwappableComponentRef; }): (props: ExternalComponentProps) => JSX.Element { - const internalRef = OpaqueComponentRef.toInternal(ref); + const internalRef = OpaqueSwappableComponentRef.toInternal(ref); const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
); @@ -69,7 +69,7 @@ function makeComponentFromRef< const ComponentRefImpl = (props: ExternalComponentProps) => { const api = useComponentRefApi(); const ComponentOrPromise = - api?.getComponent(ref)?.() ?? + api?.getComponentLoader(ref)?.() ?? internalRef.loader?.() ?? FallbackComponent; @@ -108,25 +108,26 @@ export function createSwappableComponent< TInnerComponentProps, TExternalComponentProps >, -): ((props: TExternalComponentProps) => JSX.Element) & { - ref: ComponentRef; +): ((props: TExternalComponentProps) => JSX.Element | null) & { + ref: SwappableComponentRef; } { - const ref = OpaqueComponentRef.createInstance('v1', { + const ref = OpaqueSwappableComponentRef.createInstance('v1', { id: options.id, TProps: null as unknown as TInnerComponentProps, TExternalProps: null as unknown as TExternalComponentProps, toString() { - return `ComponentRef{id=${options.id}}`; + return `SwappableComponentRef{id=${options.id}}`; }, - loader: options.loader as (typeof OpaqueComponentRef.TInternal)['loader'], + loader: + options.loader as (typeof OpaqueSwappableComponentRef.TInternal)['loader'], transformProps: - options.transformProps as (typeof OpaqueComponentRef.TInternal)['transformProps'], + options.transformProps as (typeof OpaqueSwappableComponentRef.TInternal)['transformProps'], }); const component = makeComponentFromRef({ ref }); Object.assign(component, { ref }); return component as { - ref: ComponentRef; - } & ((props: object) => JSX.Element); + ref: SwappableComponentRef; + } & ((props: object) => JSX.Element | null); } diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 6e28d769c9..450224bdc4 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -21,7 +21,7 @@ export { export { createSwappableComponent, type CreateSwappableComponentOptions, - type ComponentRef, + type SwappableComponentRef, } from './createSwappableComponent'; export { useAppNode } from './AppNodeProvider'; export * from './DefaultSwappableComponents'; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 6b9a4d3a54..c203990916 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,38 +315,6 @@ const appPlugin: FrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; - 'api:app/components': ExtensionDefinition<{ - config: {}; - configInput: {}; - output: ExtensionDataRef; - inputs: { - components: ExtensionInput< - ConfigurableExtensionDataRef< - { - ref: ComponentRef; - loader: - | (() => (props: {}) => JSX.Element | null) - | (() => Promise<(props: {}) => JSX.Element | null>); - }, - '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'; @@ -616,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.component.component', + {} + >, + { + 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: {}; @@ -735,7 +735,7 @@ const appPlugin: FrontendPlugin< configInput: {}; output: ExtensionDataRef< { - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); @@ -744,13 +744,19 @@ const appPlugin: FrontendPlugin< {} >; inputs: {}; - params: >(params: { - component: Ref extends ComponentRef + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< @@ -758,12 +764,18 @@ const appPlugin: FrontendPlugin< >) : never; }) => ExtensionBlueprintParams<{ - component: Ref extends ComponentRef + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< @@ -779,7 +791,7 @@ const appPlugin: FrontendPlugin< configInput: {}; output: ExtensionDataRef< { - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); @@ -788,13 +800,19 @@ const appPlugin: FrontendPlugin< {} >; inputs: {}; - params: >(params: { - component: Ref extends ComponentRef + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< @@ -802,12 +820,18 @@ const appPlugin: FrontendPlugin< >) : never; }) => ExtensionBlueprintParams<{ - component: Ref extends ComponentRef + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< @@ -823,7 +847,7 @@ const appPlugin: FrontendPlugin< configInput: {}; output: ExtensionDataRef< { - ref: ComponentRef; + ref: SwappableComponentRef; loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); @@ -832,13 +856,19 @@ const appPlugin: FrontendPlugin< {} >; inputs: {}; - params: >(params: { - component: Ref extends ComponentRef + params: >(params: { + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< @@ -846,12 +876,18 @@ const appPlugin: FrontendPlugin< >) : never; }) => ExtensionBlueprintParams<{ - component: Ref extends ComponentRef + component: Ref extends SwappableComponentRef< + any, + infer IExternalComponentProps + > ? { ref: Ref; - } & ((props: IExternalComponentProps) => JSX.Element) + } & ((props: IExternalComponentProps) => JSX.Element | null) : never; - loader: Ref extends ComponentRef + loader: Ref extends SwappableComponentRef< + infer IInnerComponentProps, + any + > ? | (() => (props: IInnerComponentProps) => JSX.Element | null) | (() => Promise< diff --git a/plugins/app/src/extensions/ComponentsApi.tsx b/plugins/app/src/extensions/SwappableComponentsApi.ts similarity index 78% rename from plugins/app/src/extensions/ComponentsApi.tsx rename to plugins/app/src/extensions/SwappableComponentsApi.ts index a457e05f3e..5809f90be2 100644 --- a/plugins/app/src/extensions/ComponentsApi.tsx +++ b/plugins/app/src/extensions/SwappableComponentsApi.ts @@ -18,16 +18,16 @@ import { 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( [SwappableComponentBlueprint.dataRefs.component], @@ -37,10 +37,10 @@ export const ComponentsApi = ApiBlueprint.makeWithOverrides({ factory: (originalFactory, { inputs }) => { return originalFactory(defineParams => defineParams({ - api: componentsApiRef, + api: swappableComponentsApiRef, deps: {}, factory: () => - DefaultComponentsApi.fromComponents( + DefaultSwappableComponentsApi.fromComponents( inputs.components.map(i => i.get(SwappableComponentBlueprint.dataRefs.component), ), diff --git a/plugins/app/src/extensions/index.ts b/plugins/app/src/extensions/index.ts index 4bf77b1bb5..3eea920bbd 100644 --- a/plugins/app/src/extensions/index.ts +++ b/plugins/app/src/extensions/index.ts @@ -20,7 +20,7 @@ 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'; diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index 6a965216cf..c898334430 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -25,7 +25,7 @@ import { AppThemeApi, DarkTheme, LightTheme, - ComponentsApi, + SwappableComponentsApi, IconsApi, FeatureFlagsApi, TranslationsApi, @@ -54,7 +54,7 @@ export const appPlugin = createFrontendPlugin({ AppThemeApi, DarkTheme, LightTheme, - ComponentsApi, + SwappableComponentsApi, IconsApi, FeatureFlagsApi, TranslationsApi, From 75ea6f14c5af80d1ca2e8eabeb491c79229a1336 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 7 Aug 2025 10:54:10 +0200 Subject: [PATCH 22/29] feat: refactor to error display instead Signed-off-by: benjdlambert --- .../src/compatWrapper/BackwardsCompatProvider.tsx | 4 ++-- .../src/compatWrapper/ForwardsCompatProvider.tsx | 10 +++++----- .../src/compatWrapper/compatWrapper.test.tsx | 6 +++--- .../core-compat-api/src/convertLegacyAppOptions.tsx | 10 ++++------ .../src/apis/definitions/SwappableComponentsApi.ts | 2 +- .../src/components/DefaultSwappableComponents.ts | 9 ++++----- .../src/components/ErrorBoundary.tsx | 11 ++++++----- .../src/components/ExtensionBoundary.tsx | 3 +-- .../src/components/createSwappableComponent.test.tsx | 2 +- packages/frontend-plugin-api/src/index.ts | 2 +- packages/frontend-plugin-api/src/types.ts | 2 +- plugins/app/src/extensions/components.tsx | 4 ++-- 12 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx index 2652aa69cf..6abaa433aa 100644 --- a/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/BackwardsCompatProvider.tsx @@ -27,7 +27,7 @@ import { iconsApiRef, useApi, routeResolutionApiRef, - ErrorBoundary, + ErrorDisplay, NotFoundErrorPage, Progress, } from '@backstage/frontend-plugin-api'; @@ -102,7 +102,7 @@ function LegacyAppContextProvider(props: { children: ReactNode }) { const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] = ({ plugin, ...rest }) => ( - + ); return { diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index 099cb836bb..a3a686a8d2 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -24,7 +24,7 @@ import { AnyRouteRefParams, SwappableComponentRef, SwappableComponentsApi, - CoreErrorBoundaryFallbackProps, + CoreErrorDisplayProps, CoreNotFoundErrorPageProps, CoreProgressProps, ExternalRouteRef, @@ -39,7 +39,7 @@ import { routeResolutionApiRef, Progress, NotFoundErrorPage, - ErrorBoundary, + ErrorDisplay, } from '@backstage/frontend-plugin-api'; import { ComponentType, useMemo } from 'react'; import { ReactNode } from 'react'; @@ -54,11 +54,11 @@ import { convertLegacyRouteRef } from '../convertLegacyRouteRef'; class CompatComponentsApi implements SwappableComponentsApi { readonly #Progress: ComponentType; readonly #NotFoundErrorPage: ComponentType; - readonly #ErrorBoundaryFallback: ComponentType; + readonly #ErrorBoundaryFallback: ComponentType; constructor(app: AppContext) { const components = app.getComponents(); - const ErrorBoundaryFallback = (props: CoreErrorBoundaryFallbackProps) => ( + const ErrorBoundaryFallback = (props: CoreErrorDisplayProps) => ( this.#NotFoundErrorPage) as () => ( props: object, ) => JSX.Element | null; - case ErrorBoundary.ref.id: + case ErrorDisplay.ref.id: return (() => this.#ErrorBoundaryFallback) as () => ( props: object, ) => JSX.Element | null; diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index 9accec81a6..8304150303 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -23,7 +23,7 @@ import { createRouteRef as createNewRouteRef, useApi, NotFoundErrorPage, - ErrorBoundary, + ErrorDisplay, Progress, } from '@backstage/frontend-plugin-api'; import { @@ -102,7 +102,7 @@ describe('ForwardsCompatProvider', () => { const defaultComponentRefs = { progress: Progress.ref, notFoundErrorPage: NotFoundErrorPage.ref, - errorBoundary: ErrorBoundary.ref, + errorDisplay: ErrorDisplay.ref, }; function Component() { @@ -126,7 +126,7 @@ describe('ForwardsCompatProvider', () => { await renderInOldTestApp(compatWrapper()); expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(` - "components: progress=true, notFoundErrorPage=true, errorBoundary=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 7615027a96..d298cb7bf5 100644 --- a/packages/core-compat-api/src/convertLegacyAppOptions.tsx +++ b/packages/core-compat-api/src/convertLegacyAppOptions.tsx @@ -18,7 +18,7 @@ import { ComponentType } from 'react'; import { SwappableComponentBlueprint, ApiBlueprint, - CoreErrorBoundaryFallbackProps, + CoreErrorDisplayProps, createExtension, createFrontendModule, ExtensionDefinition, @@ -27,7 +27,7 @@ import { RouterBlueprint, SignInPageBlueprint, ThemeBlueprint, - ErrorBoundary as SwappableErrorBoundary, + ErrorDisplay as SwappableErrorDisplay, NotFoundErrorPage as SwappableNotFoundErrorPage, Progress as SwappableProgress, } from '@backstage/frontend-plugin-api'; @@ -179,9 +179,7 @@ export function convertLegacyAppOptions( } if (ErrorBoundaryFallback) { - const WrappedErrorBoundaryFallback = ( - props: CoreErrorBoundaryFallbackProps, - ) => + const WrappedErrorBoundaryFallback = (props: CoreErrorDisplayProps) => compatWrapper( define({ - component: SwappableErrorBoundary, + component: SwappableErrorDisplay, loader: () => componentCompatWrapper(WrappedErrorBoundaryFallback), }), diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index aeb33afca9..29a9584a91 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -40,5 +40,5 @@ export interface SwappableComponentsApi { * @public */ export const swappableComponentsApiRef = createApiRef({ - id: 'core.swappableComponents', + id: 'core.swappable-components', }); diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts index 1ff805ba23..f6cc3345cf 100644 --- a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts @@ -15,7 +15,7 @@ */ import { - CoreErrorBoundaryFallbackProps, + CoreErrorDisplayProps, CoreNotFoundErrorPageProps, CoreProgressProps, } from '../types'; @@ -39,7 +39,6 @@ export const NotFoundErrorPage = /** * @public */ -export const ErrorBoundary = - createSwappableComponent({ - id: 'core.components.errorBoundary', - }); +export const ErrorDisplay = createSwappableComponent({ + id: 'core.components.errorDisplay', +}); 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 e88b26f1ef..823d07240b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -29,7 +29,6 @@ import { AppNode } from '../apis'; import { Progress } from '@backstage/core-components'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; -import { ErrorBoundary as ErrorBoundaryComponent } from './DefaultSwappableComponents'; type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; @@ -77,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { return ( }> - + {children} diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx index 64d989cdb9..b7871eac3c 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx @@ -21,7 +21,7 @@ describe('createSwappableComponent', () => { it('can be created and read', () => { const { ref } = createSwappableComponent({ id: 'foo' }); expect(ref.id).toBe('foo'); - expect(String(ref)).toBe('ComponentRef{id=foo}'); + expect(String(ref)).toBe('SwappableComponentRef{id=foo}'); }); it('should allow defining a default component implementation', () => { diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index f3c633c959..7d519a79da 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -34,5 +34,5 @@ export * from './wiring'; export type { CoreProgressProps, CoreNotFoundErrorPageProps, - CoreErrorBoundaryFallbackProps, + CoreErrorDisplayProps, } from './types'; diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts index 9088a9d772..5a8f6db7a7 100644 --- a/packages/frontend-plugin-api/src/types.ts +++ b/packages/frontend-plugin-api/src/types.ts @@ -26,7 +26,7 @@ export type CoreNotFoundErrorPageProps = { }; /** @public */ -export type CoreErrorBoundaryFallbackProps = { +export type CoreErrorDisplayProps = { plugin?: FrontendPlugin; error: Error; resetError: () => void; diff --git a/plugins/app/src/extensions/components.tsx b/plugins/app/src/extensions/components.tsx index 872630a995..782e457b29 100644 --- a/plugins/app/src/extensions/components.tsx +++ b/plugins/app/src/extensions/components.tsx @@ -16,7 +16,7 @@ import { NotFoundErrorPage as SwappableNotFoundErrorPage, Progress as SwappableProgress, - ErrorBoundary as SwappableErrorBoundary, + ErrorDisplay as SwappableErrorDisplay, SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; @@ -50,7 +50,7 @@ export const ErrorBoundary = SwappableComponentBlueprint.make({ name: 'core.components.errorBoundary', params: define => define({ - component: SwappableErrorBoundary, + component: SwappableErrorDisplay, loader: () => props => { const { plugin, error, resetError } = props; const title = `Error in ${plugin?.id}`; From 20f1d88971a3a38176ba24ca04442a67d43b2f37 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 7 Aug 2025 15:57:34 +0200 Subject: [PATCH 23/29] chore: some more refactor to get ready for shipping Signed-off-by: benjdlambert --- .../compatWrapper/ForwardsCompatProvider.tsx | 17 ++--- .../src/compatWrapper/compatWrapper.test.tsx | 2 +- .../DefaultSwappableComponentsApi.test.tsx | 12 +-- ...i.ts => DefaultSwappableComponentsApi.tsx} | 45 ++++++++---- .../wiring/InternalSwappableComponentRef.ts | 4 +- packages/frontend-plugin-api/report.api.md | 18 ++--- .../definitions/SwappableComponentsApi.ts | 7 +- .../SwappableComponentBlueprint.test.tsx | 18 ++--- .../blueprints/SwappableComponentBlueprint.ts | 2 +- ...ents.ts => DefaultSwappableComponents.tsx} | 2 + .../src/components/ExtensionBoundary.tsx | 2 +- .../createSwappableComponent.test.tsx | 17 +++-- .../components/createSwappableComponent.tsx | 73 +++++++------------ 13 files changed, 94 insertions(+), 125 deletions(-) rename packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/{DefaultSwappableComponentsApi.ts => DefaultSwappableComponentsApi.tsx} (55%) rename packages/frontend-plugin-api/src/components/{DefaultSwappableComponents.ts => DefaultSwappableComponents.tsx} (91%) diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index a3a686a8d2..a5b7d31141 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -69,26 +69,19 @@ class CompatComponentsApi implements SwappableComponentsApi { this.#ErrorBoundaryFallback = ErrorBoundaryFallback; } - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined { + ): (props: TInnerComponentProps) => JSX.Element | null { switch (ref.id) { case Progress.ref.id: - return (() => this.#Progress) as () => ( - props: object, - ) => JSX.Element | null; + return this.#Progress as (props: object) => JSX.Element | null; case NotFoundErrorPage.ref.id: - return (() => this.#NotFoundErrorPage) as () => ( - props: object, - ) => JSX.Element | null; + return this.#NotFoundErrorPage as (props: object) => JSX.Element | null; case ErrorDisplay.ref.id: - return (() => this.#ErrorBoundaryFallback) as () => ( + return this.#ErrorBoundaryFallback as ( props: object, ) => JSX.Element | null; default: diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index 8304150303..fe9dab9910 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -114,7 +114,7 @@ describe('ForwardsCompatProvider', () => { {Object.entries(defaultComponentRefs) .map( ([name, ref]) => - `${name}=${Boolean(components.getComponentLoader(ref))}`, + `${name}=${Boolean(components.getComponent(ref))}`, ) .join(', ')} {'\n'} 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 index 6afe3c2e19..2a91525601 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -31,9 +31,7 @@ describe('DefaultComponentsApi', () => { }, ]); - const ComponentA = api.getComponentLoader( - testRefA, - )?.() as () => JSX.Element; + const ComponentA = api.getComponent(testRefA); render(); @@ -49,13 +47,9 @@ describe('DefaultComponentsApi', () => { }, ]); - const ComponentB2 = api.getComponentLoader( - testRefB2, - )?.() as () => JSX.Element; + const ComponentB2 = api.getComponent(testRefB2); - const ComponentB1 = api.getComponentLoader( - testRefB1, - )?.() as () => JSX.Element; + const ComponentB1 = api.getComponent(testRefB1); expect(ComponentB1).toBe(ComponentB2); diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx similarity index 55% rename from packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx index 885b7bdddb..dbead31f35 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx @@ -19,6 +19,9 @@ import { SwappableComponentsApi, SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; +import { OpaqueSwappableComponentRef } from '@internal/frontend'; + +import { lazy } from 'react'; /** * Implementation for the {@linkComponentApi} @@ -26,18 +29,24 @@ import { * @internal */ export class DefaultSwappableComponentsApi implements SwappableComponentsApi { - #components: Map< - string, - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>) - | undefined - >; + #components: Map JSX.Element | null) | undefined>; static fromComponents( components: Array, ) { return new DefaultSwappableComponentsApi( - new Map(components.map(entry => [entry.ref.id, entry.loader])), + new Map( + components.map(entry => { + return [ + entry.ref.id, + entry.loader + ? lazy(async () => ({ + default: await entry.loader!(), + })) + : undefined, + ]; + }), + ), ); } @@ -45,13 +54,21 @@ export class DefaultSwappableComponentsApi implements SwappableComponentsApi { this.#components = components; } - getComponentLoader( + getComponent( ref: SwappableComponentRef, - ): - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>) - | undefined { - const impl = this.#components.get(ref.id); - return impl; + ): (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-internal/src/wiring/InternalSwappableComponentRef.ts b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts index 7fe27920a4..2a5da3fa73 100644 --- a/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts +++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts @@ -22,9 +22,7 @@ export const OpaqueSwappableComponentRef = OpaqueType.create<{ versions: { readonly version: 'v1'; readonly transformProps?: (props: object) => object; - readonly loader?: - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>); + readonly defaultComponent: (props: object) => JSX.Element | null; }; }>({ versions: ['v1'], diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index e4815fb98b..d60ce91cb7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -375,7 +375,7 @@ export interface ConfigurableExtensionDataRef< } // @public (undocumented) -export type CoreErrorBoundaryFallbackProps = { +export type CoreErrorDisplayProps = { plugin?: FrontendPlugin; error: Error; resetError: () => void; @@ -871,13 +871,10 @@ export { ErrorApiErrorContext }; export { errorApiRef }; // @public (undocumented) -export const ErrorBoundary: (( - props: CoreErrorBoundaryFallbackProps, +export const ErrorDisplay: (( + props: CoreErrorDisplayProps, ) => JSX.Element | null) & { - ref: SwappableComponentRef< - CoreErrorBoundaryFallbackProps, - CoreErrorBoundaryFallbackProps - >; + ref: SwappableComponentRef; }; // @public (undocumented) @@ -1899,15 +1896,12 @@ export type SwappableComponentRef< // @public export interface SwappableComponentsApi { // (undocumented) - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined; + ): (props: TInnerComponentProps) => JSX.Element | null; } // @public diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index 29a9584a91..ed2b20ce80 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -23,15 +23,12 @@ import { createApiRef } from '@backstage/core-plugin-api'; * @public */ export interface SwappableComponentsApi { - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined; + ): (props: TInnerComponentProps) => JSX.Element | null; } /** diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx index 8fae754799..fae4bf7ebf 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx @@ -112,26 +112,20 @@ describe('SwappableComponentBlueprint', () => { await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); }); - it('should allow overriding a component ref with the blueprint', async () => { + it('should render a component ref with an async loader implementation and prop transform', async () => { const TestComponent = createSwappableComponent({ id: 'test.component', - loader: () => (props: { hello: string }) =>
{props.hello}
, - }); - - const extension = SwappableComponentBlueprint.make({ - params: define => - define({ - component: TestComponent, - loader: () => props =>
Override {props.hello}
, - }), + loader: async () => (props: { hello: string }) => +
{props.hello}
, + transformProps: ({ hello }) => ({ hello: `tr ${hello}` }), }); renderInTestApp(
, { extensions: [ - extension, PageBlueprint.make({ params: define => define({ + // todo(blam): there's a bug that this path cannot be `/`? path: '/test', loader: async () => , }), @@ -141,7 +135,7 @@ describe('SwappableComponentBlueprint', () => { }); await waitFor(() => - expect(screen.getByText('Override test!')).toBeInTheDocument(), + 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 index 60807f96c5..931d15f3ab 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -34,7 +34,7 @@ export const componentDataRef = createExtensionDataRef<{ */ export const SwappableComponentBlueprint = createExtensionBlueprint({ kind: 'component', - attachTo: { id: 'api:app/components', input: 'components' }, + attachTo: { id: 'api:app/swappable-components', input: 'components' }, output: [componentDataRef], dataRefs: { component: componentDataRef, diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx similarity index 91% rename from packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts rename to packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx index f6cc3345cf..6684dafce6 100644 --- a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx @@ -41,4 +41,6 @@ export const NotFoundErrorPage = */ export const ErrorDisplay = createSwappableComponent({ id: 'core.components.errorDisplay', + loader: () => props => +
{props.error.message}
, }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 823d07240b..270168b88b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -26,7 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; import { AppNode } from '../apis'; -import { Progress } from '@backstage/core-components'; +import { Progress } from '@backstage/frontend-plugin-api'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx index b7871eac3c..2a73a4da42 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx @@ -66,7 +66,7 @@ describe('createSwappableComponent', () => { }); describe('sync', () => { - it('should create a component from a ref for sync component', () => { + it('should create a component from a ref for sync component', async () => { const Component = createSwappableComponent({ id: 'random', loader: () => (props: { name: string }) => { @@ -79,20 +79,21 @@ describe('createSwappableComponent', () => { render(); - expect(screen.getByTestId('test')).toHaveTextContent('test'); + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'test', + ); }); - it('should render a fallback when theres no default implementation provided', () => { + it('should render a fallback when theres no default implementation provided', async () => { const Component = createSwappableComponent({ id: 'random', }); render(); - - expect(screen.getByTestId('random')).toBeInTheDocument(); + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); }); - it('should map props from external to internal', () => { + it('should map props from external to internal', async () => { const Component = createSwappableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ @@ -108,7 +109,9 @@ describe('createSwappableComponent', () => { render(); - expect(screen.getByTestId('test')).toHaveTextContent('TEST'); + 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 index 1218dbc8d2..7df8c2e0c3 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -16,7 +16,7 @@ import { OpaqueSwappableComponentRef } from '@internal/frontend'; import { swappableComponentsApiRef, useApi } from '../apis'; -import { lazy, Suspense } from 'react'; +import { lazy } from 'react'; /** @public */ export type SwappableComponentRef< @@ -53,48 +53,6 @@ const useComponentRefApi = () => { } }; -function makeComponentFromRef< - InternalComponentProps extends {}, - ExternalComponentProps extends {}, ->({ - ref, -}: { - ref: SwappableComponentRef; -}): (props: ExternalComponentProps) => JSX.Element { - const internalRef = OpaqueSwappableComponentRef.toInternal(ref); - const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( -
- ); - - const ComponentRefImpl = (props: ExternalComponentProps) => { - const api = useComponentRefApi(); - const ComponentOrPromise = - api?.getComponentLoader(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; -} - /** * Creates a SwappableComponent that can be used to render the component, optionally overriden by the app. * @@ -111,6 +69,10 @@ export function createSwappableComponent< ): ((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, @@ -118,16 +80,31 @@ export function createSwappableComponent< toString() { return `SwappableComponentRef{id=${options.id}}`; }, - loader: - options.loader as (typeof OpaqueSwappableComponentRef.TInternal)['loader'], + 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 component = makeComponentFromRef({ ref }); - Object.assign(component, { ref }); + const ComponentRefImpl = (props: TExternalComponentProps) => { + const api = useComponentRefApi(); - return component as { + 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: object) => JSX.Element | null); } From de3ad4aced9a3bf68eeea821e0b003cd51c81272 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 7 Aug 2025 16:49:07 +0200 Subject: [PATCH 24/29] chore: fixing tests Signed-off-by: benjdlambert --- .../DefaultSwappableComponentsApi.test.tsx | 14 +++++--------- packages/frontend-defaults/src/createApp.test.tsx | 4 ++-- .../src/components/ExtensionBoundary.tsx | 2 +- .../src/components/createSwappableComponent.tsx | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) 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 index 2a91525601..e71ad50bfb 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -23,7 +23,7 @@ const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' }); const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' }); describe('DefaultComponentsApi', () => { - it('should provide components', () => { + it('should provide components', async () => { const api = DefaultSwappableComponentsApi.fromComponents([ { ref: testRefA, @@ -35,10 +35,10 @@ describe('DefaultComponentsApi', () => { render(); - expect(screen.getByText('test.a')).toBeInTheDocument(); + await expect(screen.findByText('test.a')).resolves.toBeInTheDocument(); }); - it('should key extension refs by ID', () => { + it('should key extension refs by ID', async () => { const mockLoader = jest.fn(() =>
test.b
); const api = DefaultSwappableComponentsApi.fromComponents([ { @@ -49,12 +49,8 @@ describe('DefaultComponentsApi', () => { const ComponentB2 = api.getComponent(testRefB2); - const ComponentB1 = api.getComponent(testRefB1); + render(); - expect(ComponentB1).toBe(ComponentB2); - - render(); - - expect(screen.getByText('test.b')).toBeInTheDocument(); + await expect(screen.findByText('test.b')).resolves.toBeInTheDocument(); }); }); diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index cc9a7ba3cd..a923a70f38 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/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 270168b88b..848ea64a2c 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -26,9 +26,9 @@ import { ErrorBoundary } from './ErrorBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; import { AppNode } from '../apis'; -import { Progress } from '@backstage/frontend-plugin-api'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; +import { Progress } from './DefaultSwappableComponents'; type RouteTrackerProps = PropsWithChildren<{ enabled?: boolean; diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx index 7df8c2e0c3..0a82fab9c7 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -106,5 +106,5 @@ export function createSwappableComponent< return ComponentRefImpl as { ref: SwappableComponentRef; - } & ((props: object) => JSX.Element | null); + } & ((props: TExternalComponentProps) => JSX.Element | null); } From b9758e4a28f672151204c5cbca5767973f37aee0 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 7 Aug 2025 17:00:23 +0200 Subject: [PATCH 25/29] chore: added some last tests Signed-off-by: benjdlambert --- .../DefaultSwappableComponentsApi.test.tsx | 212 +++++++++++++++++- .../src/extensions/SwappableComponentsApi.ts | 7 +- 2 files changed, 213 insertions(+), 6 deletions(-) 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 index e71ad50bfb..78b4c6f596 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -14,15 +14,22 @@ * limitations under the License. */ -import { createSwappableComponent } from '@backstage/frontend-plugin-api'; +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('DefaultComponentsApi', () => { +describe('DefaultSwappableComponentsApi', () => { it('should provide components', async () => { const api = DefaultSwappableComponentsApi.fromComponents([ { @@ -53,4 +60,205 @@ describe('DefaultComponentsApi', () => { 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: () => () =>
Overriden!
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('Overriden!'), + ).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 () => () =>
Overriden!
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('Overriden!'), + ).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 =>
overriden: {props.inner}
, + }), + }); + + renderInTestApp(, { + extensions: [api, override], + }); + + await expect( + screen.findByText('overriden: test'), + ).resolves.toBeInTheDocument(); + }); + }); + }); }); diff --git a/plugins/app/src/extensions/SwappableComponentsApi.ts b/plugins/app/src/extensions/SwappableComponentsApi.ts index 5809f90be2..3964fc172a 100644 --- a/plugins/app/src/extensions/SwappableComponentsApi.ts +++ b/plugins/app/src/extensions/SwappableComponentsApi.ts @@ -29,10 +29,9 @@ import { DefaultSwappableComponentsApi } from '../../../../packages/frontend-app export const SwappableComponentsApi = ApiBlueprint.makeWithOverrides({ name: 'swappable-components', inputs: { - components: createExtensionInput( - [SwappableComponentBlueprint.dataRefs.component], - { replaces: [{ id: 'app', input: 'components' }] }, - ), + components: createExtensionInput([ + SwappableComponentBlueprint.dataRefs.component, + ]), }, factory: (originalFactory, { inputs }) => { return originalFactory(defineParams => From 8a19aba608b568338b5e475d8069449ccd329a2c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 7 Aug 2025 17:12:46 +0200 Subject: [PATCH 26/29] chore: add deps Signed-off-by: benjdlambert --- packages/frontend-app-api/package.json | 1 + packages/frontend-defaults/src/createApp.test.tsx | 6 +++--- .../src/blueprints/SwappableComponentBlueprint.ts | 2 +- yarn.lock | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) 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-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index a923a70f38..35a69654e9 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -368,9 +368,9 @@ describe('createApp', () => {
components [ - - - + + + ] diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts index 931d15f3ab..b72d14d87f 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -25,7 +25,7 @@ export const componentDataRef = createExtensionDataRef<{ loader: | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); -}>().with({ id: 'core.component.component' }); +}>().with({ id: 'core.swappableComponent' }); /** * Blueprint for creating swappable components from a SwappableComponentRef and a loader diff --git a/yarn.lock b/yarn.lock index f329407da3..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:^" From fda1bbc515c98a214f5ac7e94643ed6e66c0f081 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 8 Aug 2025 09:29:16 +0200 Subject: [PATCH 27/29] chore: fixing ref names and changesets Signed-off-by: benjdlambert --- .changeset/component-refs-app.md | 5 + .changeset/component-refs-breaking-app.md | 15 +++ ...onent-refs-breaking-frontend-plugin-api.md | 92 +++++++++++++++++++ .changeset/fuzzy-ducks-jump.md | 5 + .changeset/strong-dogs-raise.md | 5 + packages/frontend-plugin-api/report.api.md | 4 +- plugins/app/report.api.md | 8 +- 7 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 .changeset/component-refs-app.md create mode 100644 .changeset/component-refs-breaking-app.md create mode 100644 .changeset/component-refs-breaking-frontend-plugin-api.md create mode 100644 .changeset/fuzzy-ducks-jump.md create mode 100644 .changeset/strong-dogs-raise.md 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..6d43547d21 --- /dev/null +++ b/.changeset/component-refs-breaking-frontend-plugin-api.md @@ -0,0 +1,92 @@ +--- +'@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` + +**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 + + + +``` + +**BREAKING**: The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively. diff --git a/.changeset/fuzzy-ducks-jump.md b/.changeset/fuzzy-ducks-jump.md new file mode 100644 index 0000000000..5f5930a8dc --- /dev/null +++ b/.changeset/fuzzy-ducks-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-compat-api': patch +--- + +This is primarily an internal change that ensures legacy plugins continue to work with the new component system without requiring immediate migration. 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/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index d60ce91cb7..7a17e38f92 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1862,7 +1862,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >; inputs: {}; @@ -1876,7 +1876,7 @@ export const SwappableComponentBlueprint: ExtensionBlueprint<{ | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >; }; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index c203990916..423da8ec89 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -597,7 +597,7 @@ const appPlugin: FrontendPlugin< | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >, { @@ -740,7 +740,7 @@ const appPlugin: FrontendPlugin< | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >; inputs: {}; @@ -796,7 +796,7 @@ const appPlugin: FrontendPlugin< | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >; inputs: {}; @@ -852,7 +852,7 @@ const appPlugin: FrontendPlugin< | (() => (props: {}) => JSX.Element | null) | (() => Promise<(props: {}) => JSX.Element | null>); }, - 'core.component.component', + 'core.swappableComponent', {} >; inputs: {}; From f4b9fd74ca108988e01cb2b7fe194521301f2d05 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 8 Aug 2025 12:08:23 +0200 Subject: [PATCH 28/29] chore: some changeset tweaks a few other changes Signed-off-by: benjdlambert --- ...onent-refs-breaking-frontend-plugin-api.md | 8 +++- .changeset/fuzzy-ducks-jump.md | 4 +- .../config/vocabularies/Backstage/accept.txt | 2 +- .../compatWrapper/ForwardsCompatProvider.tsx | 14 +++--- .../src/convertLegacyAppOptions.tsx | 4 +- .../DefaultSwappableComponentsApi.tsx | 2 +- packages/frontend-plugin-api/report.api.md | 45 +++++++++---------- .../components/DefaultSwappableComponents.tsx | 12 ++--- packages/frontend-plugin-api/src/index.ts | 6 +-- packages/frontend-plugin-api/src/types.ts | 6 +-- 10 files changed, 52 insertions(+), 51 deletions(-) diff --git a/.changeset/component-refs-breaking-frontend-plugin-api.md b/.changeset/component-refs-breaking-frontend-plugin-api.md index 6d43547d21..47c40865d3 100644 --- a/.changeset/component-refs-breaking-frontend-plugin-api.md +++ b/.changeset/component-refs-breaking-frontend-plugin-api.md @@ -7,6 +7,12 @@ - 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 @@ -88,5 +94,3 @@ import { Progress, NotFoundErrorPage, ErrorDisplay } from '@backstage/frontend-p ``` - -**BREAKING**: The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively. diff --git a/.changeset/fuzzy-ducks-jump.md b/.changeset/fuzzy-ducks-jump.md index 5f5930a8dc..9aa2288598 100644 --- a/.changeset/fuzzy-ducks-jump.md +++ b/.changeset/fuzzy-ducks-jump.md @@ -1,5 +1,5 @@ --- -'@backstage/core-compat-api': patch +'@backstage/core-compat-api': minor --- -This is primarily an internal change that ensures legacy plugins continue to work with the new component system without requiring immediate migration. +**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/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index aa6de7c545..5eba84e61d 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -467,7 +467,7 @@ Superfences superset supertype SVGs -Swappable +swappable talkdesk Talkdesk Tanzu diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index a5b7d31141..6464032673 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -24,9 +24,9 @@ import { AnyRouteRefParams, SwappableComponentRef, SwappableComponentsApi, - CoreErrorDisplayProps, - CoreNotFoundErrorPageProps, - CoreProgressProps, + ErrorDisplayProps, + NotFoundErrorPageProps, + ProgressProps, ExternalRouteRef, IconComponent, IconsApi, @@ -52,13 +52,13 @@ import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRout import { convertLegacyRouteRef } from '../convertLegacyRouteRef'; class CompatComponentsApi implements SwappableComponentsApi { - readonly #Progress: ComponentType; - readonly #NotFoundErrorPage: ComponentType; - readonly #ErrorBoundaryFallback: ComponentType; + readonly #Progress: ComponentType; + readonly #NotFoundErrorPage: ComponentType; + readonly #ErrorBoundaryFallback: ComponentType; constructor(app: AppContext) { const components = app.getComponents(); - const ErrorBoundaryFallback = (props: CoreErrorDisplayProps) => ( + const ErrorBoundaryFallback = (props: ErrorDisplayProps) => ( + const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) => compatWrapper( ; } -// @public (undocumented) -export type CoreErrorDisplayProps = { - plugin?: FrontendPlugin; - error: Error; - resetError: () => void; -}; - // @public (undocumented) export const coreExtensionData: { reactElement: ConfigurableExtensionDataRef< @@ -396,14 +389,6 @@ export const coreExtensionData: { >; }; -// @public (undocumented) -export type CoreNotFoundErrorPageProps = { - children?: ReactNode; -}; - -// @public (undocumented) -export type CoreProgressProps = {}; - export { createApiFactory }; export { createApiRef }; @@ -872,9 +857,16 @@ export { errorApiRef }; // @public (undocumented) export const ErrorDisplay: (( - props: CoreErrorDisplayProps, + props: ErrorDisplayProps, ) => JSX.Element | null) & { - ref: SwappableComponentRef; + ref: SwappableComponentRef; +}; + +// @public (undocumented) +export type ErrorDisplayProps = { + plugin?: FrontendPlugin; + error: Error; + resetError: () => void; }; // @public (undocumented) @@ -1534,12 +1526,14 @@ export const NavItemBlueprint: ExtensionBlueprint<{ // @public (undocumented) export const NotFoundErrorPage: (( - props: CoreNotFoundErrorPageProps, + props: NotFoundErrorPageProps, ) => JSX.Element | null) & { - ref: SwappableComponentRef< - CoreNotFoundErrorPageProps, - CoreNotFoundErrorPageProps - >; + ref: SwappableComponentRef; +}; + +// @public (undocumented) +export type NotFoundErrorPageProps = { + children?: ReactNode; }; export { OAuthApi }; @@ -1627,10 +1621,13 @@ export { ProfileInfo }; export { ProfileInfoApi }; // @public (undocumented) -export const Progress: ((props: CoreProgressProps) => JSX.Element | null) & { - ref: SwappableComponentRef; +export const Progress: ((props: ProgressProps) => JSX.Element | null) & { + ref: SwappableComponentRef; }; +// @public (undocumented) +export type ProgressProps = {}; + // @public export type ResolvedExtensionInput< TExtensionInput extends ExtensionInput, diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx index 6684dafce6..e9a4220142 100644 --- a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx @@ -15,16 +15,16 @@ */ import { - CoreErrorDisplayProps, - CoreNotFoundErrorPageProps, - CoreProgressProps, + ErrorDisplayProps, + NotFoundErrorPageProps, + ProgressProps, } from '../types'; import { createSwappableComponent } from './createSwappableComponent'; /** * @public */ -export const Progress = createSwappableComponent({ +export const Progress = createSwappableComponent({ id: 'core.components.progress', }); @@ -32,14 +32,14 @@ export const Progress = createSwappableComponent({ * @public */ export const NotFoundErrorPage = - createSwappableComponent({ + createSwappableComponent({ id: 'core.components.notFoundErrorPage', }); /** * @public */ -export const ErrorDisplay = createSwappableComponent({ +export const ErrorDisplay = createSwappableComponent({ id: 'core.components.errorDisplay', loader: () => props =>
{props.error.message}
, diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index 7d519a79da..9117dc0ef9 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -32,7 +32,7 @@ export * from './translation'; export * from './wiring'; export type { - CoreProgressProps, - CoreNotFoundErrorPageProps, - CoreErrorDisplayProps, + ProgressProps, + NotFoundErrorPageProps, + ErrorDisplayProps, } from './types'; diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts index 5a8f6db7a7..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 CoreErrorDisplayProps = { +export type ErrorDisplayProps = { plugin?: FrontendPlugin; error: Error; resetError: () => void; From e0fd7f404671070b1390bdb8db353db55c1a59f5 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 8 Aug 2025 12:10:15 +0200 Subject: [PATCH 29/29] chore: `overriden` -> `overridden` Signed-off-by: benjdlambert --- .../DefaultSwappableComponentsApi.test.tsx | 12 ++++++------ .../src/components/createSwappableComponent.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) 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 index 78b4c6f596..823d48e21c 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -197,7 +197,7 @@ describe('DefaultSwappableComponentsApi', () => { params: define => define({ component: MockComponent, - loader: () => () =>
Overriden!
, + loader: () => () =>
Overridden!
, }), }); @@ -206,7 +206,7 @@ describe('DefaultSwappableComponentsApi', () => { }); await expect( - screen.findByText('Overriden!'), + screen.findByText('Overridden!'), ).resolves.toBeInTheDocument(); }); @@ -220,7 +220,7 @@ describe('DefaultSwappableComponentsApi', () => { params: define => define({ component: MockComponent, - loader: async () => () =>
Overriden!
, + loader: async () => () =>
Overridden!
, }), }); @@ -229,7 +229,7 @@ describe('DefaultSwappableComponentsApi', () => { }); await expect( - screen.findByText('Overriden!'), + screen.findByText('Overridden!'), ).resolves.toBeInTheDocument(); }); @@ -247,7 +247,7 @@ describe('DefaultSwappableComponentsApi', () => { params: define => define({ component: MockComponent, - loader: () => props =>
overriden: {props.inner}
, + loader: () => props =>
overridden: {props.inner}
, }), }); @@ -256,7 +256,7 @@ describe('DefaultSwappableComponentsApi', () => { }); await expect( - screen.findByText('overriden: test'), + screen.findByText('overridden: test'), ).resolves.toBeInTheDocument(); }); }); diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx index 0a82fab9c7..b977a46839 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -54,7 +54,7 @@ const useComponentRefApi = () => { }; /** - * Creates a SwappableComponent that can be used to render the component, optionally overriden by the app. + * Creates a SwappableComponent that can be used to render the component, optionally overridden by the app. * * @public */