diff --git a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts index 64412b8710..0a4aa10cf6 100644 --- a/packages/frontend-plugin-api/src/components/coreComponentRefs.ts +++ b/packages/frontend-plugin-api/src/components/coreComponentRefs.ts @@ -23,16 +23,19 @@ import { createComponentRef } from './createComponentRef'; const coreProgressComponentRef = createComponentRef({ id: 'core.components.progress', + mode: 'sync', }); const coreNotFoundErrorPageComponentRef = createComponentRef({ id: 'core.components.notFoundErrorPage', + mode: 'sync', }); const coreErrorBoundaryFallbackComponentRef = createComponentRef({ id: 'core.components.errorBoundaryFallback', + mode: 'sync', }); /** @public */ diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx index 32912ca3d5..751daa0c3f 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.test.tsx @@ -29,26 +29,29 @@ describe('createComponentRef', () => { createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'sync', - defaultComponent: ({ bar }) => , + defaultComponent: ({ foo }) => , }); createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'async', - defaultComponent: async () => , + defaultComponent: + async () => + ({ foo }) => + , }); - // @ts-expect-error - this should be an error as mode is sync createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'sync', - defaultComponent: async ({ bar }) => , + // @ts-expect-error - this should be an error as mode is sync + defaultComponent: async ({ foo }) => , }); - // @ts-expect-error - this should be an error as mode is async createComponentRef<{ foo: string }, { bar: string }>({ id: 'foo', mode: 'async', + // @ts-expect-error - this should be an error as mode is async defaultComponent: ({ bar }) => , }); @@ -74,6 +77,12 @@ describe('createComponentRef', () => { transformProps: props => ({ foo: 1 }), }); + createComponentRef<{ foo: string }, { bar: string }>({ + id: 'foo', + mode: 'sync', + transformProps: props => ({ foo: props.bar }), + }); + expect(true).toBe(true); }); }); diff --git a/packages/frontend-plugin-api/src/components/createComponentRef.tsx b/packages/frontend-plugin-api/src/components/createComponentRef.tsx index 37095ca055..3912b6bdad 100644 --- a/packages/frontend-plugin-api/src/components/createComponentRef.tsx +++ b/packages/frontend-plugin-api/src/components/createComponentRef.tsx @@ -14,33 +14,30 @@ * limitations under the License. */ +import { OpaqueComponentRef } from '@internal/frontend'; + /** @public */ export type ComponentRef< - TInnerComponentProps, - TExternalComponentProps, - TMode extends 'sync' | 'async', + TInnerComponentProps = {}, + TExternalComponentProps = TInnerComponentProps, > = { id: string; - mode: TMode; - transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; - defaultComponent?: TMode extends 'async' - ? (props: TExternalComponentProps) => Promise - : TMode extends 'sync' - ? (props: TExternalComponentProps) => JSX.Element - : never; + TProps: TInnerComponentProps; + TExternalProps: TExternalComponentProps; + $$type: '@backstage/ComponentRef'; }; export interface ComponentRefOptions< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object, TMode extends 'sync' | 'async', > { id: string; mode: TMode; defaultComponent?: TMode extends 'async' - ? (props: TExternalComponentProps) => Promise + ? () => Promise<(props: TInnerComponentProps) => JSX.Element> : TMode extends 'sync' - ? (props: TExternalComponentProps) => JSX.Element + ? (props: TInnerComponentProps) => JSX.Element : never; transformProps?: (props: TExternalComponentProps) => TInnerComponentProps; } @@ -50,51 +47,52 @@ export interface ComponentRefOptions< * @public */ export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, 'sync' >, -): ComponentRef; +): ComponentRef; /** * Creates a new component ref that is asynchronous. * @public */ export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, + TInnerComponentProps extends object, + TExternalComponentProps extends object = TInnerComponentProps, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, 'async' >, -): ComponentRef; +): ComponentRef; export function createComponentRef< - TInnerComponentProps, - TExternalComponentProps, - TMode extends 'sync' | 'async', + TInnerComponentProps extends object, + TExternalComponentProps extends object, >( options: ComponentRefOptions< TInnerComponentProps, TExternalComponentProps, - TMode + 'async' | 'sync' >, -): ComponentRef { - const { id, mode, defaultComponent, transformProps } = options; - - return { - id, - mode, - defaultComponent, - transformProps, +): ComponentRef { + return OpaqueComponentRef.createInstance('v1', { + id: options.id, + TProps: {} as TInnerComponentProps, + TExternalProps: {} as TExternalComponentProps, toString() { - return `ComponentRef{id=${id}}`; + return `ComponentRef{id=${options.id}}`; }, - } as ComponentRef; + options: { + mode: options.mode, + defaultComponent: options.defaultComponent, + transformProps: options.transformProps, + }, + }); } diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx new file mode 100644 index 0000000000..ffaa9d5ccf --- /dev/null +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react'; +import { createComponentRef } from './createComponentRef'; +import { makeComponentFromRef } from './makeComponentFromRef'; + +describe('makeComponentFromRef', () => { + describe('sync', () => { + it('should create a component from a ref for sync component', () => { + const ref = createComponentRef({ + id: 'random', + mode: 'sync', + defaultComponent: (props: { name: string }) => { + return
{props.name}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + expect(screen.getByTestId('test')).toHaveTextContent('test'); + }); + + it('should render a fallback when theres no default implementation provided', () => { + const ref = createComponentRef({ + id: 'random', + mode: 'sync', + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + expect(screen.getByTestId('random')).toBeInTheDocument(); + }); + + it('should map props from external to internal', () => { + const ref = createComponentRef({ + id: 'random', + mode: 'sync', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + defaultComponent: props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + expect(screen.getByTestId('test')).toHaveTextContent('TEST'); + }); + }); + + describe('async', () => { + it('should create a component from a ref for async component', async () => { + const ref = createComponentRef({ + id: 'random', + mode: 'async', + defaultComponent: async () => (props: { name: string }) => { + return
{props.name}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toBeInTheDocument(); + }); + + it('should render a fallback when theres no default implementation provided', async () => { + const ref = createComponentRef({ + id: 'random', + mode: 'async', + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); + }); + + it('should map props from external to internal', async () => { + const ref = createComponentRef({ + id: 'random', + mode: 'async', + transformProps: (props: { name: string }) => ({ + uppercase: props.name.toUpperCase(), + }), + defaultComponent: async () => props => { + // @ts-expect-error as uppercase is types as a string + const test: number = props.uppercase; + + return
{props.uppercase}
; + }, + }); + + const Component = makeComponentFromRef({ ref }); + + render(); + + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'TEST', + ); + }); + }); +}); diff --git a/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx new file mode 100644 index 0000000000..ef3ca0ac4b --- /dev/null +++ b/packages/frontend-plugin-api/src/components/makeComponentFromRef.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { lazy, Suspense } from 'react'; +import { ComponentRef } from './createComponentRef'; +import { OpaqueComponentRef } from '@internal/frontend'; +import { Progress } from '@backstage/core-components'; + +export function makeComponentFromRef< + InternalComponentProps extends object, + ExternalComponentProps extends object, +>({ + ref, +}: { + ref: ComponentRef; +}): (props: ExternalComponentProps) => JSX.Element { + const { options } = OpaqueComponentRef.toInternal(ref); + const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( +
+ ); + + const ComponentRefImpl = (props: ExternalComponentProps) => { + const innerProps = options.transformProps?.(props) ?? props; + + if (options.mode === 'sync') { + const DefaultImplementation = + options.defaultComponent ?? FallbackComponent; + + return ; + } + + const DefaultImplementation = lazy( + () => + options.defaultComponent?.().then(c => { + return { default: c }; + }) ?? + Promise.resolve({ + default: FallbackComponent, + }), + ); + + return ( + + + + ); + }; + + return ComponentRefImpl; +}