feat: implementing makeComponentFromRef

Signed-off-by: benjdlambert <ben@blam.sh>

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-07-28 16:41:00 +02:00
parent 6f8a093ac6
commit e09f27a9a8
5 changed files with 239 additions and 39 deletions
@@ -23,16 +23,19 @@ import { createComponentRef } from './createComponentRef';
const coreProgressComponentRef = createComponentRef<CoreProgressProps>({
id: 'core.components.progress',
mode: 'sync',
});
const coreNotFoundErrorPageComponentRef =
createComponentRef<CoreNotFoundErrorPageProps>({
id: 'core.components.notFoundErrorPage',
mode: 'sync',
});
const coreErrorBoundaryFallbackComponentRef =
createComponentRef<CoreErrorBoundaryFallbackProps>({
id: 'core.components.errorBoundaryFallback',
mode: 'sync',
});
/** @public */
@@ -29,26 +29,29 @@ describe('createComponentRef', () => {
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
defaultComponent: ({ bar }) => <Test key={bar} />,
defaultComponent: ({ foo }) => <Test key={foo} />,
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'async',
defaultComponent: async () => <Test />,
defaultComponent:
async () =>
({ foo }) =>
<Test key={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 }) => <Test key={bar} />,
// @ts-expect-error - this should be an error as mode is sync
defaultComponent: async ({ foo }) => <Test key={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 }) => <Test key={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);
});
});
@@ -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<JSX.Element>
: 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<JSX.Element>
? () => 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<TInnerComponentProps, TExternalComponentProps, 'sync'>;
): ComponentRef<TInnerComponentProps, TExternalComponentProps>;
/**
* 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<TInnerComponentProps, TExternalComponentProps, 'async'>;
): ComponentRef<TInnerComponentProps, TExternalComponentProps>;
export function createComponentRef<
TInnerComponentProps,
TExternalComponentProps,
TMode extends 'sync' | 'async',
TInnerComponentProps extends object,
TExternalComponentProps extends object,
>(
options: ComponentRefOptions<
TInnerComponentProps,
TExternalComponentProps,
TMode
'async' | 'sync'
>,
): ComponentRef<TInnerComponentProps, TExternalComponentProps, TMode> {
const { id, mode, defaultComponent, transformProps } = options;
return {
id,
mode,
defaultComponent,
transformProps,
): ComponentRef<TInnerComponentProps, TExternalComponentProps> {
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<TInnerComponentProps, TExternalComponentProps, TMode>;
options: {
mode: options.mode,
defaultComponent: options.defaultComponent,
transformProps: options.transformProps,
},
});
}
@@ -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 <div data-testid="test">{props.name}</div>;
},
});
const Component = makeComponentFromRef({ ref });
render(<Component name="test" />);
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(<Component />);
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 <div data-testid="test">{props.uppercase}</div>;
},
});
const Component = makeComponentFromRef({ ref });
render(<Component name="test" />);
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 <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({
id: 'random',
mode: 'async',
});
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({
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 <div data-testid="test">{props.uppercase}</div>;
},
});
const Component = makeComponentFromRef({ ref });
render(<Component name="test" />);
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
'TEST',
);
});
});
});
@@ -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<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) => {
const innerProps = options.transformProps?.(props) ?? props;
if (options.mode === 'sync') {
const DefaultImplementation =
options.defaultComponent ?? FallbackComponent;
return <DefaultImplementation {...innerProps} />;
}
const DefaultImplementation = lazy(
() =>
options.defaultComponent?.().then(c => {
return { default: c };
}) ??
Promise.resolve({
default: FallbackComponent,
}),
);
return (
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
};
return ComponentRefImpl;
}