Merge pull request #30750 from backstage/mob/component-refs-wth

`nfs(componentRefs)`: Reworking how Component Refs are defined and used
This commit is contained in:
Ben Lambert
2025-08-08 12:52:36 +02:00
committed by GitHub
48 changed files with 1593 additions and 632 deletions
+143 -97
View File
@@ -350,21 +350,6 @@ export { bitbucketAuthApiRef };
export { bitbucketServerAuthApiRef };
// @public (undocumented)
export type ComponentRef<T extends {} = {}> = {
id: string;
T: T;
};
// @public
export interface ComponentsApi {
// (undocumented)
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T>;
}
// @public
export const componentsApiRef: ApiRef<ComponentsApi>;
export { ConfigApi };
export { configApiRef };
@@ -389,20 +374,6 @@ export interface ConfigurableExtensionDataRef<
>;
}
// @public (undocumented)
export const coreComponentRefs: {
progress: ComponentRef<CoreProgressProps>;
notFoundErrorPage: ComponentRef<CoreNotFoundErrorPageProps>;
errorBoundaryFallback: ComponentRef<CoreErrorBoundaryFallbackProps>;
};
// @public (undocumented)
export type CoreErrorBoundaryFallbackProps = {
plugin?: FrontendPlugin;
error: Error;
resetError: () => void;
};
// @public (undocumented)
export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef<
@@ -418,73 +389,10 @@ export const coreExtensionData: {
>;
};
// @public (undocumented)
export type CoreNotFoundErrorPageProps = {
children?: ReactNode;
};
// @public (undocumented)
export type CoreProgressProps = {};
export { createApiFactory };
export { createApiRef };
// @public (undocumented)
export function createComponentExtension<TProps extends {}>(options: {
ref: ComponentRef<TProps>;
name?: string;
disabled?: boolean;
loader:
| {
lazy: () => Promise<ComponentType<TProps>>;
}
| {
sync: () => ComponentType<TProps>;
};
}): 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<T extends {} = {}>(options: {
id: string;
}): ComponentRef<T>;
// @public (undocumented)
export function createExtension<
UOutput extends ExtensionDataRef,
@@ -849,6 +757,31 @@ export function createSubRouteRef<
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
// @public
export function createSwappableComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
options: CreateSwappableComponentOptions<
TInnerComponentProps,
TExternalComponentProps
>,
): ((props: TExternalComponentProps) => JSX.Element | null) & {
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>;
};
// @public
export type CreateSwappableComponentOptions<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
> = {
id: string;
loader?:
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
};
export { createTranslationMessages };
export { createTranslationRef };
@@ -899,6 +832,20 @@ export { ErrorApiErrorContext };
export { errorApiRef };
// @public (undocumented)
export const ErrorDisplay: ((
props: ErrorDisplayProps,
) => JSX.Element | null) & {
ref: SwappableComponentRef<ErrorDisplayProps, ErrorDisplayProps>;
};
// @public (undocumented)
export type ErrorDisplayProps = {
plugin?: FrontendPlugin;
error: Error;
resetError: () => void;
};
// @public (undocumented)
export interface Extension<TConfig, TConfigInput = TConfig> {
// (undocumented)
@@ -1554,6 +1501,18 @@ export const NavItemBlueprint: ExtensionBlueprint<{
};
}>;
// @public (undocumented)
export const NotFoundErrorPage: ((
props: NotFoundErrorPageProps,
) => JSX.Element | null) & {
ref: SwappableComponentRef<NotFoundErrorPageProps, NotFoundErrorPageProps>;
};
// @public (undocumented)
export type NotFoundErrorPageProps = {
children?: ReactNode;
};
export { OAuthApi };
export { OAuthRequestApi };
@@ -1638,6 +1597,14 @@ export { ProfileInfo };
export { ProfileInfoApi };
// @public (undocumented)
export const Progress: ((props: ProgressProps) => JSX.Element | null) & {
ref: SwappableComponentRef<ProgressProps, ProgressProps>;
};
// @public (undocumented)
export type ProgressProps = {};
// @public
export type ResolvedExtensionInput<
TExtensionInput extends ExtensionInput<any, any>,
@@ -1830,6 +1797,90 @@ export interface SubRouteRef<
readonly T: TParams;
}
// @public
export const SwappableComponentBlueprint: ExtensionBlueprint<{
kind: 'component';
params: <Ref extends SwappableComponentRef<any>>(params: {
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}) => ExtensionBlueprintParams<{
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? {
ref: Ref;
} & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}>;
output: ExtensionDataRef<
{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
},
'core.swappableComponent',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef<
{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
},
'core.swappableComponent',
{}
>;
};
}>;
// @public (undocumented)
export type SwappableComponentRef<
TInnerComponentProps extends {} = {},
TExternalComponentProps extends {} = TInnerComponentProps,
> = {
id: string;
TProps: TInnerComponentProps;
TExternalProps: TExternalComponentProps;
$$type: '@backstage/SwappableComponentRef';
};
// @public
export interface SwappableComponentsApi {
// (undocumented)
getComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
): (props: TInnerComponentProps) => JSX.Element | null;
}
// @public
export const swappableComponentsApiRef: ApiRef<SwappableComponentsApi>;
// @public
export const ThemeBlueprint: ExtensionBlueprint<{
kind: 'theme';
@@ -1906,11 +1957,6 @@ export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
// @public
export function useComponentRef<T extends {}>(
ref: ComponentRef<T>,
): ComponentType<T>;
// @public
export function useRouteRef<TParams extends AnyRouteRefParams>(
routeRef:
@@ -1,49 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentType } from 'react';
import { createApiRef, useApi } from '@backstage/core-plugin-api';
import { ComponentRef } from '../../components';
/**
* API for looking up components based on component refs.
*
* @public
*/
export interface ComponentsApi {
// TODO: Should component refs also provide the default implementation so that we're guaranteed to get a component?
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T>;
}
/**
* The `ApiRef` of {@link ComponentsApi}.
*
* @public
*/
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);
}
@@ -0,0 +1,41 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SwappableComponentRef } from '../../components';
import { createApiRef } from '@backstage/core-plugin-api';
/**
* API for looking up components based on component refs.
*
* @public
*/
export interface SwappableComponentsApi {
getComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
): (props: TInnerComponentProps) => JSX.Element | null;
}
/**
* The `ApiRef` of {@link SwappableComponentsApi}.
*
* @public
*/
export const swappableComponentsApiRef = createApiRef<SwappableComponentsApi>({
id: 'core.swappable-components',
});
@@ -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';
@@ -0,0 +1,141 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/frontend-test-utils';
import { createSwappableComponent } from '../components';
import { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
import { PageBlueprint } from './PageBlueprint';
import { waitFor, screen } from '@testing-library/react';
describe('SwappableComponentBlueprint', () => {
it('should allow defining a component override for a component ref', () => {
const Component = createSwappableComponent({
id: 'test.component',
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
});
const extension = SwappableComponentBlueprint.make({
params: define =>
define({
component: Component,
loader: () => props => {
// @ts-expect-error
const t: number = props.hello;
return <div>Override {props.hello}</div>;
},
}),
});
expect(extension).toBeDefined();
});
it('should render default component refs in the app', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
});
renderInTestApp(<div />, {
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
initialRouteEntries: ['/test'],
});
await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
});
it('should render a component ref without a default implementation', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
});
renderInTestApp(<div />, {
extensions: [
PageBlueprint.make({
params: define =>
define({
path: '/test',
loader: async () => <TestComponent />,
}),
}),
],
initialRouteEntries: ['/test'],
});
await waitFor(() =>
expect(screen.getByTestId('test.component')).toBeInTheDocument(),
);
});
it('should render a component ref with an async loader implementation', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: async () => (props: { hello: string }) =>
<div>{props.hello}</div>,
});
renderInTestApp(<div />, {
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
initialRouteEntries: ['/test'],
});
await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
});
it('should render a component ref with an async loader implementation and prop transform', async () => {
const TestComponent = createSwappableComponent({
id: 'test.component',
loader: async () => (props: { hello: string }) =>
<div>{props.hello}</div>,
transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
});
renderInTestApp(<div />, {
extensions: [
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
loader: async () => <TestComponent hello="test!" />,
}),
}),
],
initialRouteEntries: ['/test'],
});
await waitFor(() =>
expect(screen.getByText('tr test!')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,63 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SwappableComponentRef } from '../components';
import {
createExtensionBlueprint,
createExtensionBlueprintParams,
createExtensionDataRef,
} from '../wiring';
export const componentDataRef = createExtensionDataRef<{
ref: SwappableComponentRef;
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
}>().with({ id: 'core.swappableComponent' });
/**
* Blueprint for creating swappable components from a SwappableComponentRef and a loader
*
* @public
*/
export const SwappableComponentBlueprint = createExtensionBlueprint({
kind: 'component',
attachTo: { id: 'api:app/swappable-components', input: 'components' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
defineParams<Ref extends SwappableComponentRef<any>>(params: {
component: Ref extends SwappableComponentRef<
any,
infer IExternalComponentProps
>
? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null)
: never;
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}) {
return createExtensionBlueprintParams(params);
},
factory: params => [
componentDataRef({
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 { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
@@ -0,0 +1,46 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ErrorDisplayProps,
NotFoundErrorPageProps,
ProgressProps,
} from '../types';
import { createSwappableComponent } from './createSwappableComponent';
/**
* @public
*/
export const Progress = createSwappableComponent<ProgressProps>({
id: 'core.components.progress',
});
/**
* @public
*/
export const NotFoundErrorPage =
createSwappableComponent<NotFoundErrorPageProps>({
id: 'core.components.notFoundErrorPage',
});
/**
* @public
*/
export const ErrorDisplay = createSwappableComponent<ErrorDisplayProps>({
id: 'core.components.errorDisplay',
loader: () => props =>
<div data-testid="core.components.errorDisplay">{props.error.message}</div>,
});
@@ -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<CoreErrorBoundaryFallbackProps>;
}>;
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 (
<Fallback
<ErrorDisplay
// todo: do we want to just use useAppNode hook in the ErrorDisplay instead?
plugin={plugin}
error={error}
// todo: probably change this to onResetError
resetError={this.handleErrorReset}
/>
);
@@ -25,10 +25,10 @@ import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
import { ErrorBoundary } from './ErrorBoundary';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
import { AppNode } from '../apis';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
import { Progress } from './DefaultSwappableComponents';
type RouteTrackerProps = PropsWithChildren<{
enabled?: boolean;
@@ -66,8 +66,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
);
const plugin = node.spec.plugin;
const Progress = useComponentRef(coreComponentRefs.progress);
const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
const attributes = {
@@ -78,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
return (
<AppNodeProvider node={node}>
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<ErrorBoundary plugin={plugin}>
<AnalyticsContext attributes={attributes}>
<RouteTracker enabled={hasRoutePathOutput}>{children}</RouteTracker>
</AnalyticsContext>
@@ -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,25 +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}');
});
});
@@ -1,34 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @public */
export type ComponentRef<T extends {} = {}> = {
id: string;
T: T;
};
/** @public */
export function createComponentRef<T extends {} = {}>(options: {
id: string;
}): ComponentRef<T> {
const { id } = options;
return {
id,
toString() {
return `ComponentRef{id=${id}}`;
},
} as ComponentRef<T>;
}
@@ -0,0 +1,163 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react';
import { createSwappableComponent } from './createSwappableComponent';
describe('createSwappableComponent', () => {
it('can be created and read', () => {
const { ref } = createSwappableComponent({ id: 'foo' });
expect(ref.id).toBe('foo');
expect(String(ref)).toBe('SwappableComponentRef{id=foo}');
});
it('should allow defining a default component implementation', () => {
const Test = () => <div>test</div>;
createSwappableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
() =>
({ foo }) =>
<Test key={foo} />,
});
createSwappableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
loader:
async () =>
({ foo }) =>
<Test key={foo} />,
});
createSwappableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
});
expect(Test).toBeDefined();
});
it('should allow transformings props', () => {
createSwappableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
transformProps: props => ({ foo: props.bar }),
});
createSwappableComponent<{ foo: string }, { bar: string }>({
id: 'foo',
// @ts-expect-error - this should be an error as foo is not a string
transformProps: props => ({ foo: 1 }),
});
expect(true).toBe(true);
});
describe('sync', () => {
it('should create a component from a ref for sync component', async () => {
const Component = createSwappableComponent({
id: 'random',
loader: () => (props: { name: string }) => {
return <div data-testid="test">{props.name}</div>;
},
transformProps: (props: { id: string }) => ({
name: props.id,
}),
});
render(<Component id="test" />);
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
'test',
);
});
it('should render a fallback when theres no default implementation provided', async () => {
const Component = createSwappableComponent({
id: 'random',
});
render(<Component />);
await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
});
it('should map props from external to internal', async () => {
const Component = createSwappableComponent({
id: 'random',
transformProps: (props: { name: string }) => ({
uppercase: props.name.toUpperCase(),
}),
loader: () => props => {
// @ts-expect-error as uppercase is types as a string
const test: number = props.uppercase;
return <div data-testid="test">{props.uppercase}</div>;
},
});
render(<Component name="test" />);
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
'TEST',
);
});
});
describe('async', () => {
it('should create a component from a ref for async component', async () => {
const Component = createSwappableComponent({
id: 'random',
loader: async () => (props: { name: string }) => {
return <div data-testid="test">{props.name}</div>;
},
});
render(<Component name="test" />);
await expect(screen.findByTestId('test')).resolves.toBeInTheDocument();
});
it('should render a fallback when theres no default implementation provided', async () => {
const Component = createSwappableComponent({
id: 'random',
});
render(<Component />);
await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
});
it('should map props from external to internal', async () => {
const Component = createSwappableComponent({
id: 'random',
transformProps: (props: { name: string }) => ({
uppercase: props.name.toUpperCase(),
}),
loader: async () => props => {
// @ts-expect-error as uppercase is types as a string
const test: number = props.uppercase;
return <div data-testid="test">{props.uppercase}</div>;
},
});
render(<Component name="test" />);
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
'TEST',
);
});
});
});
@@ -0,0 +1,110 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OpaqueSwappableComponentRef } from '@internal/frontend';
import { swappableComponentsApiRef, useApi } from '../apis';
import { lazy } from 'react';
/** @public */
export type SwappableComponentRef<
TInnerComponentProps extends {} = {},
TExternalComponentProps extends {} = TInnerComponentProps,
> = {
id: string;
TProps: TInnerComponentProps;
TExternalProps: TExternalComponentProps;
$$type: '@backstage/SwappableComponentRef';
};
/**
* Options for creating an SwappableComponent.
*
* @public
*/
export type CreateSwappableComponentOptions<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
> = {
id: string;
loader?:
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
};
const useComponentRefApi = () => {
try {
return useApi(swappableComponentsApiRef);
} catch (e) {
return undefined;
}
};
/**
* Creates a SwappableComponent that can be used to render the component, optionally overridden by the app.
*
* @public
*/
export function createSwappableComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
options: CreateSwappableComponentOptions<
TInnerComponentProps,
TExternalComponentProps
>,
): ((props: TExternalComponentProps) => JSX.Element | null) & {
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>;
} {
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
<div data-testid={options.id} {...p} />
);
const ref = OpaqueSwappableComponentRef.createInstance('v1', {
id: options.id,
TProps: null as unknown as TInnerComponentProps,
TExternalProps: null as unknown as TExternalComponentProps,
toString() {
return `SwappableComponentRef{id=${options.id}}`;
},
defaultComponent: lazy(async () => {
const Component = (await options.loader?.()) ?? FallbackComponent;
return { default: Component };
}) as (typeof OpaqueSwappableComponentRef.TInternal)['defaultComponent'],
transformProps:
options.transformProps as (typeof OpaqueSwappableComponentRef.TInternal)['transformProps'],
});
const ComponentRefImpl = (props: TExternalComponentProps) => {
const api = useComponentRefApi();
if (!api) {
const internalRef = OpaqueSwappableComponentRef.toInternal(ref);
const Component = internalRef.defaultComponent;
const innerProps = internalRef.transformProps?.(props) ?? props;
return <Component {...innerProps} />;
}
const Component = api.getComponent<any>(ref);
return <Component {...props} />;
};
Object.assign(ComponentRefImpl, { ref });
return ComponentRefImpl as {
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>;
} & ((props: TExternalComponentProps) => JSX.Element | null);
}
@@ -18,6 +18,10 @@ export {
ExtensionBoundary,
type ExtensionBoundaryProps,
} from './ExtensionBoundary';
export { coreComponentRefs } from './coreComponentRefs';
export { createComponentRef, type ComponentRef } from './createComponentRef';
export {
createSwappableComponent,
type CreateSwappableComponentOptions,
type SwappableComponentRef,
} from './createSwappableComponent';
export { useAppNode } from './AppNodeProvider';
export * from './DefaultSwappableComponents';
@@ -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';
+3 -4
View File
@@ -24,7 +24,6 @@ export * from './analytics';
export * from './apis';
export * from './blueprints';
export * from './components';
export * from './extensions';
export * from './icons';
export * from './routing';
export * from './schema';
@@ -33,7 +32,7 @@ export * from './translation';
export * from './wiring';
export type {
CoreProgressProps,
CoreNotFoundErrorPageProps,
CoreErrorBoundaryFallbackProps,
ProgressProps,
NotFoundErrorPageProps,
ErrorDisplayProps,
} from './types';
+3 -3
View File
@@ -18,15 +18,15 @@ import { ReactNode } from 'react';
import { FrontendPlugin } from './wiring';
/** @public */
export type CoreProgressProps = {};
export type ProgressProps = {};
/** @public */
export type CoreNotFoundErrorPageProps = {
export type NotFoundErrorPageProps = {
children?: ReactNode;
};
/** @public */
export type CoreErrorBoundaryFallbackProps = {
export type ErrorDisplayProps = {
plugin?: FrontendPlugin;
error: Error;
resetError: () => void;
@@ -214,7 +214,6 @@ type AnyParamsInput<TParams extends object | ExtensionBlueprintDefineParams> =
* @public
*/
export interface ExtensionBlueprint<
// TParamsMapper extends (params: any) => object,
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
> {
dataRefs: T['dataRefs'];