chore: some more refactor to get ready for shipping
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -69,26 +69,19 @@ class CompatComponentsApi implements SwappableComponentsApi {
|
||||
this.#ErrorBoundaryFallback = ErrorBoundaryFallback;
|
||||
}
|
||||
|
||||
getComponentLoader<
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
):
|
||||
| (() => (props: TInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
|
||||
| undefined {
|
||||
): (props: TInnerComponentProps) => JSX.Element | null {
|
||||
switch (ref.id) {
|
||||
case Progress.ref.id:
|
||||
return (() => this.#Progress) as () => (
|
||||
props: object,
|
||||
) => JSX.Element | null;
|
||||
return this.#Progress as (props: object) => JSX.Element | null;
|
||||
case NotFoundErrorPage.ref.id:
|
||||
return (() => this.#NotFoundErrorPage) as () => (
|
||||
props: object,
|
||||
) => JSX.Element | null;
|
||||
return this.#NotFoundErrorPage as (props: object) => JSX.Element | null;
|
||||
case ErrorDisplay.ref.id:
|
||||
return (() => this.#ErrorBoundaryFallback) as () => (
|
||||
return this.#ErrorBoundaryFallback as (
|
||||
props: object,
|
||||
) => JSX.Element | null;
|
||||
default:
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('ForwardsCompatProvider', () => {
|
||||
{Object.entries(defaultComponentRefs)
|
||||
.map(
|
||||
([name, ref]) =>
|
||||
`${name}=${Boolean(components.getComponentLoader(ref))}`,
|
||||
`${name}=${Boolean(components.getComponent(ref))}`,
|
||||
)
|
||||
.join(', ')}
|
||||
{'\n'}
|
||||
|
||||
+3
-9
@@ -31,9 +31,7 @@ describe('DefaultComponentsApi', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentA = api.getComponentLoader(
|
||||
testRefA,
|
||||
)?.() as () => JSX.Element;
|
||||
const ComponentA = api.getComponent(testRefA);
|
||||
|
||||
render(<ComponentA />);
|
||||
|
||||
@@ -49,13 +47,9 @@ describe('DefaultComponentsApi', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentB2 = api.getComponentLoader(
|
||||
testRefB2,
|
||||
)?.() as () => JSX.Element;
|
||||
const ComponentB2 = api.getComponent(testRefB2);
|
||||
|
||||
const ComponentB1 = api.getComponentLoader(
|
||||
testRefB1,
|
||||
)?.() as () => JSX.Element;
|
||||
const ComponentB1 = api.getComponent(testRefB1);
|
||||
|
||||
expect(ComponentB1).toBe(ComponentB2);
|
||||
|
||||
|
||||
+31
-14
@@ -19,6 +19,9 @@ import {
|
||||
SwappableComponentsApi,
|
||||
SwappableComponentBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueSwappableComponentRef } from '@internal/frontend';
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
/**
|
||||
* Implementation for the {@linkComponentApi}
|
||||
@@ -26,18 +29,24 @@ import {
|
||||
* @internal
|
||||
*/
|
||||
export class DefaultSwappableComponentsApi implements SwappableComponentsApi {
|
||||
#components: Map<
|
||||
string,
|
||||
| (() => (props: object) => JSX.Element | null)
|
||||
| (() => Promise<(props: object) => JSX.Element | null>)
|
||||
| undefined
|
||||
>;
|
||||
#components: Map<string, ((props: object) => JSX.Element | null) | undefined>;
|
||||
|
||||
static fromComponents(
|
||||
components: Array<typeof SwappableComponentBlueprint.dataRefs.component.T>,
|
||||
) {
|
||||
return new DefaultSwappableComponentsApi(
|
||||
new Map(components.map(entry => [entry.ref.id, entry.loader])),
|
||||
new Map(
|
||||
components.map(entry => {
|
||||
return [
|
||||
entry.ref.id,
|
||||
entry.loader
|
||||
? lazy(async () => ({
|
||||
default: await entry.loader!(),
|
||||
}))
|
||||
: undefined,
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,13 +54,21 @@ export class DefaultSwappableComponentsApi implements SwappableComponentsApi {
|
||||
this.#components = components;
|
||||
}
|
||||
|
||||
getComponentLoader(
|
||||
getComponent(
|
||||
ref: SwappableComponentRef<any>,
|
||||
):
|
||||
| (() => (props: object) => JSX.Element | null)
|
||||
| (() => Promise<(props: object) => JSX.Element | null>)
|
||||
| undefined {
|
||||
const impl = this.#components.get(ref.id);
|
||||
return impl;
|
||||
): (props: object) => JSX.Element | null {
|
||||
const OverrideComponent = this.#components.get(ref.id);
|
||||
const { defaultComponent: DefaultComponent, transformProps } =
|
||||
OpaqueSwappableComponentRef.toInternal(ref);
|
||||
|
||||
return (props: object) => {
|
||||
const innerProps = transformProps?.(props) ?? props;
|
||||
|
||||
if (OverrideComponent) {
|
||||
return <OverrideComponent {...innerProps} />;
|
||||
}
|
||||
|
||||
return <DefaultComponent {...innerProps} />;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,7 @@ export const OpaqueSwappableComponentRef = OpaqueType.create<{
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
readonly transformProps?: (props: object) => object;
|
||||
readonly loader?:
|
||||
| (() => (props: object) => JSX.Element | null)
|
||||
| (() => Promise<(props: object) => JSX.Element | null>);
|
||||
readonly defaultComponent: (props: object) => JSX.Element | null;
|
||||
};
|
||||
}>({
|
||||
versions: ['v1'],
|
||||
|
||||
@@ -375,7 +375,7 @@ export interface ConfigurableExtensionDataRef<
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type CoreErrorBoundaryFallbackProps = {
|
||||
export type CoreErrorDisplayProps = {
|
||||
plugin?: FrontendPlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
@@ -871,13 +871,10 @@ export { ErrorApiErrorContext };
|
||||
export { errorApiRef };
|
||||
|
||||
// @public (undocumented)
|
||||
export const ErrorBoundary: ((
|
||||
props: CoreErrorBoundaryFallbackProps,
|
||||
export const ErrorDisplay: ((
|
||||
props: CoreErrorDisplayProps,
|
||||
) => JSX.Element | null) & {
|
||||
ref: SwappableComponentRef<
|
||||
CoreErrorBoundaryFallbackProps,
|
||||
CoreErrorBoundaryFallbackProps
|
||||
>;
|
||||
ref: SwappableComponentRef<CoreErrorDisplayProps, CoreErrorDisplayProps>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -1899,15 +1896,12 @@ export type SwappableComponentRef<
|
||||
// @public
|
||||
export interface SwappableComponentsApi {
|
||||
// (undocumented)
|
||||
getComponentLoader<
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
):
|
||||
| (() => (props: TInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
|
||||
| undefined;
|
||||
): (props: TInnerComponentProps) => JSX.Element | null;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -23,15 +23,12 @@ import { createApiRef } from '@backstage/core-plugin-api';
|
||||
* @public
|
||||
*/
|
||||
export interface SwappableComponentsApi {
|
||||
getComponentLoader<
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
):
|
||||
| (() => (props: TInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
|
||||
| undefined;
|
||||
): (props: TInnerComponentProps) => JSX.Element | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -112,26 +112,20 @@ describe('SwappableComponentBlueprint', () => {
|
||||
await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should allow overriding a component ref with the blueprint', async () => {
|
||||
it('should render a component ref with an async loader implementation and prop transform', async () => {
|
||||
const TestComponent = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
|
||||
});
|
||||
|
||||
const extension = SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: TestComponent,
|
||||
loader: () => props => <div>Override {props.hello}</div>,
|
||||
}),
|
||||
loader: async () => (props: { hello: string }) =>
|
||||
<div>{props.hello}</div>,
|
||||
transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
|
||||
});
|
||||
|
||||
renderInTestApp(<div />, {
|
||||
extensions: [
|
||||
extension,
|
||||
PageBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
// todo(blam): there's a bug that this path cannot be `/`?
|
||||
path: '/test',
|
||||
loader: async () => <TestComponent hello="test!" />,
|
||||
}),
|
||||
@@ -141,7 +135,7 @@ describe('SwappableComponentBlueprint', () => {
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Override test!')).toBeInTheDocument(),
|
||||
expect(screen.getByText('tr test!')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export const componentDataRef = createExtensionDataRef<{
|
||||
*/
|
||||
export const SwappableComponentBlueprint = createExtensionBlueprint({
|
||||
kind: 'component',
|
||||
attachTo: { id: 'api:app/components', input: 'components' },
|
||||
attachTo: { id: 'api:app/swappable-components', input: 'components' },
|
||||
output: [componentDataRef],
|
||||
dataRefs: {
|
||||
component: componentDataRef,
|
||||
|
||||
+2
@@ -41,4 +41,6 @@ export const NotFoundErrorPage =
|
||||
*/
|
||||
export const ErrorDisplay = createSwappableComponent<CoreErrorDisplayProps>({
|
||||
id: 'core.components.errorDisplay',
|
||||
loader: () => props =>
|
||||
<div data-testid="core.components.errorDisplay">{props.error.message}</div>,
|
||||
});
|
||||
@@ -26,7 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
|
||||
import { AppNode } from '../apis';
|
||||
import { Progress } from '@backstage/core-components';
|
||||
import { Progress } from '@backstage/frontend-plugin-api';
|
||||
import { coreExtensionData } from '../wiring';
|
||||
import { AppNodeProvider } from './AppNodeProvider';
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('createSwappableComponent', () => {
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
it('should create a component from a ref for sync component', () => {
|
||||
it('should create a component from a ref for sync component', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
loader: () => (props: { name: string }) => {
|
||||
@@ -79,20 +79,21 @@ describe('createSwappableComponent', () => {
|
||||
|
||||
render(<Component id="test" />);
|
||||
|
||||
expect(screen.getByTestId('test')).toHaveTextContent('test');
|
||||
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
|
||||
'test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a fallback when theres no default implementation provided', () => {
|
||||
it('should render a fallback when theres no default implementation provided', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
});
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByTestId('random')).toBeInTheDocument();
|
||||
await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map props from external to internal', () => {
|
||||
it('should map props from external to internal', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
transformProps: (props: { name: string }) => ({
|
||||
@@ -108,7 +109,9 @@ describe('createSwappableComponent', () => {
|
||||
|
||||
render(<Component name="test" />);
|
||||
|
||||
expect(screen.getByTestId('test')).toHaveTextContent('TEST');
|
||||
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
|
||||
'TEST',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { OpaqueSwappableComponentRef } from '@internal/frontend';
|
||||
import { swappableComponentsApiRef, useApi } from '../apis';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy } from 'react';
|
||||
|
||||
/** @public */
|
||||
export type SwappableComponentRef<
|
||||
@@ -53,48 +53,6 @@ const useComponentRefApi = () => {
|
||||
}
|
||||
};
|
||||
|
||||
function makeComponentFromRef<
|
||||
InternalComponentProps extends {},
|
||||
ExternalComponentProps extends {},
|
||||
>({
|
||||
ref,
|
||||
}: {
|
||||
ref: SwappableComponentRef<InternalComponentProps, ExternalComponentProps>;
|
||||
}): (props: ExternalComponentProps) => JSX.Element {
|
||||
const internalRef = OpaqueSwappableComponentRef.toInternal(ref);
|
||||
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
|
||||
<div data-testid={ref.id} {...p} />
|
||||
);
|
||||
|
||||
const ComponentRefImpl = (props: ExternalComponentProps) => {
|
||||
const api = useComponentRefApi();
|
||||
const ComponentOrPromise =
|
||||
api?.getComponentLoader<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SwappableComponent that can be used to render the component, optionally overriden by the app.
|
||||
*
|
||||
@@ -111,6 +69,10 @@ export function createSwappableComponent<
|
||||
): ((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,
|
||||
@@ -118,16 +80,31 @@ export function createSwappableComponent<
|
||||
toString() {
|
||||
return `SwappableComponentRef{id=${options.id}}`;
|
||||
},
|
||||
loader:
|
||||
options.loader as (typeof OpaqueSwappableComponentRef.TInternal)['loader'],
|
||||
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 component = makeComponentFromRef({ ref });
|
||||
Object.assign(component, { ref });
|
||||
const ComponentRefImpl = (props: TExternalComponentProps) => {
|
||||
const api = useComponentRefApi();
|
||||
|
||||
return component as {
|
||||
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: object) => JSX.Element | null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user