feat: implementing makeComponentFromRef
Signed-off-by: benjdlambert <ben@blam.sh> Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user