feat: big refactor of componentRefs again to move away from makeComponentRefs

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-08-05 14:27:04 +02:00
parent e3c320a514
commit 71510fb812
28 changed files with 310 additions and 499 deletions
@@ -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,
}),
});
@@ -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 }) => (
<ErrorBoundaryFallback
{...rest}
plugin={plugin && toNewPlugin(plugin)}
/>
<ErrorBoundary {...rest} plugin={plugin && toNewPlugin(plugin)} />
);
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 (
<AppContextProvider appContext={appContext}>
@@ -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<TProps extends {}>(
Component: ComponentType<TProps>,
@@ -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),
}),
}),
);
}
@@ -21,7 +21,10 @@ import LinearProgress, {
import { useTheme } from '@material-ui/core/styles';
import { PropsWithChildren, useEffect, useState } from 'react';
export function Progress(props: PropsWithChildren<LinearProgressProps>) {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createAdaptableComponent } from '../../../../frontend-plugin-api/src/components/createAdaptableComponent';
function ProgressComponent(props: PropsWithChildren<LinearProgressProps>) {
const theme = useTheme();
const [isVisible, setIsVisible] = useState(false);
@@ -39,3 +42,8 @@ export function Progress(props: PropsWithChildren<LinearProgressProps>) {
<Box display="none" data-testid="progress" />
);
}
export const Progress = createAdaptableComponent({
id: 'core.components.progress',
loader: () => ProgressComponent,
});
@@ -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: () => <div>test.a</div>,
loader: () => () => <div>test.a</div>,
},
]);
const ComponentA = api.getComponent(testRefA);
const ComponentA = api.getComponent(testRefA)?.() as () => JSX.Element;
render(<ComponentA />);
expect(screen.getByText('test.a')).toBeInTheDocument();
@@ -41,12 +42,12 @@ describe('DefaultComponentsApi', () => {
const api = DefaultComponentsApi.fromComponents([
{
ref: testRefB1,
impl: () => <div>test.b</div>,
loader: () => () => <div>test.b</div>,
},
]);
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);
@@ -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<string, ComponentType<any>>;
#components: Map<
string,
| (() => (props: object) => JSX.Element | null)
| (() => Promise<(props: object) => JSX.Element | null>)
| undefined
>;
static fromComponents(
components: Array<typeof createComponentExtension.componentDataRef.T>,
components: Array<typeof AdaptableComponentBlueprint.dataRefs.component.T>,
) {
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<T extends {}>(ref: ComponentRef<T>): ComponentType<T> {
getComponent(
ref: ComponentRef<any>,
):
| (() => (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;
}
}
@@ -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'],
@@ -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<T extends {}>(ref: ComponentRef<T>): ComponentType<T>;
getComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
ref: ComponentRef<TInnerComponentProps, TExternalComponentProps>,
):
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
| undefined;
}
/**
@@ -36,14 +42,3 @@ export interface ComponentsApi {
export const componentsApiRef = createApiRef<ComponentsApi>({
id: 'core.components',
});
/**
* @public
* Returns the component associated with the given ref.
*/
export function useComponentRef<T extends {}>(
ref: ComponentRef<T>,
): ComponentType<T> {
const componentsApi = useApi(componentsApiRef);
return componentsApi.getComponent<T>(ref);
}
@@ -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 }) => <div>{props.hello}</div>,
});
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 }) => <div>{props.hello}</div>,
});
const TestComponent = makeComponentFromRef({ ref: testComponentRef });
renderInTestApp(<div />, {
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(<div />, {
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 }) =>
<div>{props.hello}</div>,
});
const TestComponent = makeComponentFromRef({ ref: testComponentRef });
renderInTestApp(<div />, {
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 }) => <div>{props.hello}</div>,
});
const TestComponent = makeComponentFromRef({ ref: testComponentRef });
const extension = ComponentImplementationBlueprint.make({
const extension = AdaptableComponentBlueprint.make({
params: define =>
define({
ref: testComponentRef,
component: TestComponent,
loader: () => props => <div>Override {props.hello}</div>,
}),
});
@@ -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<Ref extends ComponentRef<any>>(params: {
ref: Ref;
component: Ref extends ComponentRef<any, infer IExternalComponentProps>
? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element)
: never;
loader: Ref extends ComponentRef<infer IInnerComponentProps, any>
?
| (() => (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,
}),
],
@@ -33,3 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
export { ThemeBlueprint } from './ThemeBlueprint';
export { TranslationBlueprint } from './TranslationBlueprint';
export { AdaptableComponentBlueprint } from './AdaptableComponentBlueprint';
@@ -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 = () => <div>Fallback</div>;
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
@@ -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<CoreProgressProps>({
id: 'core.components.progress',
});
const coreNotFoundErrorPageComponentRef =
createComponentRef<CoreNotFoundErrorPageProps>({
id: 'core.components.notFoundErrorPage',
});
const coreErrorBoundaryFallbackComponentRef =
createComponentRef<CoreErrorBoundaryFallbackProps>({
id: 'core.components.errorBoundaryFallback',
});
/** @public */
export const coreComponentRefs = {
progress: coreProgressComponentRef,
notFoundErrorPage: coreNotFoundErrorPageComponentRef,
errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef,
};
@@ -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 = () => <div>test</div>;
createAdaptableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
() =>
({ foo }) =>
<Test key={foo} />,
});
createAdaptableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
async () =>
({ foo }) =>
<Test key={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 <div data-testid="test">{props.name}</div>;
@@ -30,26 +77,23 @@ describe('makeComponentFromRef', () => {
}),
});
const Component = makeComponentFromRef({ ref });
render(<Component id="test" />);
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(<Component />);
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(<Component name="test" />);
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 <div data-testid="test">{props.name}</div>;
},
});
const Component = makeComponentFromRef({ ref });
render(<Component name="test" />);
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(<Component />);
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(<Component name="test" />);
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
@@ -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<InternalComponentProps, ExternalComponentProps>;
}): (props: ExternalComponentProps) => JSX.Element {
const internalRef = OpaqueComponentRef.toInternal(ref);
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
<div data-testid={ref.id} {...p} />
);
const ComponentRefImpl = (props: ExternalComponentProps) => {
const api = useComponentRefApi();
const ComponentOrPromise =
api?.getComponent<any>(ref)?.() ??
internalRef.loader?.() ??
FallbackComponent;
const innerProps = internalRef.transformProps?.(props) ?? props;
if ('then' in ComponentOrPromise) {
const DefaultImplementation = lazy(() =>
ComponentOrPromise.then(c => {
return { default: c };
}),
);
return (
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
}
return <ComponentOrPromise {...innerProps} />;
};
return ComponentRefImpl;
}
export function createAdaptableComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
options: ComponentRefOptions<TInnerComponentProps, TExternalComponentProps>,
): ((props: TExternalComponentProps) => JSX.Element) & {
ref: ComponentRef<TInnerComponentProps, TExternalComponentProps>;
} {
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<TInnerComponentProps, TExternalComponentProps>;
} & ((props: object) => JSX.Element);
}
@@ -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 = () => <div>test</div>;
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
() =>
({ foo }) =>
<Test key={foo} />,
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
async () =>
({ foo }) =>
<Test key={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);
});
});
@@ -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<TInnerComponentProps, TExternalComponentProps>,
): ComponentRef<TInnerComponentProps, TExternalComponentProps> {
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'],
});
}
@@ -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';
@@ -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<InternalComponentProps, ExternalComponentProps>;
}): (props: ExternalComponentProps) => JSX.Element {
const { options } = OpaqueComponentRef.toInternal(ref);
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
<div data-testid={ref.id} {...p} />
);
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?
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
}
return <ComponentOrPromise {...innerProps} />;
};
return ComponentRefImpl;
}
@@ -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<TProps extends {}>(options: {
ref: ComponentRef<TProps>;
name?: string;
disabled?: boolean;
loader:
| {
lazy: () => Promise<ComponentType<TProps>>;
}
| {
sync: () => ComponentType<TProps>;
};
}) {
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' });
}
@@ -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';
@@ -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';
+1 -6
View File
@@ -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
+3 -3
View File
@@ -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),
),
),
}),
+7 -33
View File
@@ -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 (
<ErrorPanel title={title} error={error} defaultExpanded>
<Button variant="outlined" onClick={resetError}>
Retry
</Button>
</ErrorPanel>
);
},
},
export const ErrorBoundary = createAdaptableComponent({
id: 'core.components.errorBoundaryFallback',
// todo: implementation
});
+2 -5
View File
@@ -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,
+2
View File
@@ -15,3 +15,5 @@
*/
export { appPlugin as default } from './plugin';
// todo: remove
export * from './extensions/components';
-6
View File
@@ -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,