chore: added backwards support for components api and new implementation
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiBlueprint,
|
||||
createExtensionInput,
|
||||
createSwappableComponent,
|
||||
SwappableComponentBlueprint,
|
||||
swappableComponentsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderInTestApp } from '@backstage/frontend-test-utils';
|
||||
|
||||
const { ref: testRefA } = createSwappableComponent({ id: 'test.a' });
|
||||
const { ref: testRefB1 } = createSwappableComponent({ id: 'test.b' });
|
||||
const { ref: testRefB2 } = createSwappableComponent({ id: 'test.b' });
|
||||
|
||||
describe('DefaultSwappableComponentsApi', () => {
|
||||
it('should provide components', async () => {
|
||||
const api = DefaultSwappableComponentsApi.fromComponents([
|
||||
{
|
||||
ref: testRefA,
|
||||
loader: () => () => <div>test.a</div>,
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentA = api.getComponent(testRefA);
|
||||
|
||||
render(<ComponentA />);
|
||||
|
||||
await expect(screen.findByText('test.a')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should key extension refs by ID', async () => {
|
||||
const mockLoader = jest.fn(() => <div>test.b</div>);
|
||||
const api = DefaultSwappableComponentsApi.fromComponents([
|
||||
{
|
||||
ref: testRefB1,
|
||||
loader: () => mockLoader,
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentB2 = api.getComponent(testRefB2);
|
||||
|
||||
render(<ComponentB2 />);
|
||||
|
||||
await expect(screen.findByText('test.b')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
const api = ApiBlueprint.makeWithOverrides({
|
||||
name: 'swappable-components',
|
||||
inputs: {
|
||||
components: createExtensionInput([
|
||||
SwappableComponentBlueprint.dataRefs.component,
|
||||
]),
|
||||
},
|
||||
factory: (originalFactory, { inputs }) => {
|
||||
return originalFactory(defineParams =>
|
||||
defineParams({
|
||||
api: swappableComponentsApiRef,
|
||||
deps: {},
|
||||
factory: () =>
|
||||
DefaultSwappableComponentsApi.fromComponents(
|
||||
inputs.components.map(i =>
|
||||
i.get(SwappableComponentBlueprint.dataRefs.component),
|
||||
),
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
describe('no api provided', () => {
|
||||
it('should render the fallback if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the default compnoent if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {});
|
||||
|
||||
await expect(
|
||||
screen.findByText('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render async loader component if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: async () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {});
|
||||
|
||||
await expect(
|
||||
screen.findByText('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should transform props correctly', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => (props: { inner: string }) =>
|
||||
<div>inner: {props.inner}</div>,
|
||||
transformProps: (props: { external: string }) => ({
|
||||
inner: props.external,
|
||||
}),
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent external="test" />, {});
|
||||
|
||||
await expect(
|
||||
screen.findByText('inner: test'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with overrides', () => {
|
||||
it('should render the fallback if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {
|
||||
extensions: [api],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the default compnoent if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {
|
||||
extensions: [api],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render async loader component if no other component is provided', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: async () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {
|
||||
extensions: [api],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('test.mock'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component provided by the blueprint', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
const override = SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: MockComponent,
|
||||
loader: () => () => <div>Overridden!</div>,
|
||||
}),
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {
|
||||
extensions: [api, override],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Overridden!'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the async component provided by the blueprint', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => () => <div>test.mock</div>,
|
||||
});
|
||||
|
||||
const override = SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: MockComponent,
|
||||
loader: async () => () => <div>Overridden!</div>,
|
||||
}),
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent />, {
|
||||
extensions: [api, override],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Overridden!'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should transform props correctly', async () => {
|
||||
const MockComponent = createSwappableComponent({
|
||||
id: 'test.mock',
|
||||
loader: () => (props: { inner: string }) =>
|
||||
<div>inner: {props.inner}</div>,
|
||||
transformProps: (props: { external: string }) => ({
|
||||
inner: props.external,
|
||||
}),
|
||||
});
|
||||
|
||||
const override = SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: MockComponent,
|
||||
loader: () => props => <div>overridden: {props.inner}</div>,
|
||||
}),
|
||||
});
|
||||
|
||||
renderInTestApp(<MockComponent external="test" />, {
|
||||
extensions: [api, override],
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('overridden: test'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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,
|
||||
SwappableComponentsApi,
|
||||
SwappableComponentBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueSwappableComponentRef } from '@internal/frontend';
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
/**
|
||||
* Implementation for the {@link SwappableComponentsApi}
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class DefaultSwappableComponentsApi implements SwappableComponentsApi {
|
||||
#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 => {
|
||||
return [
|
||||
entry.ref.id,
|
||||
entry.loader
|
||||
? lazy(async () => ({
|
||||
default: await entry.loader!(),
|
||||
}))
|
||||
: undefined,
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(components: Map<string, any>) {
|
||||
this.#components = components;
|
||||
}
|
||||
|
||||
getComponent(
|
||||
ref: SwappableComponentRef<any>,
|
||||
): (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} />;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2024 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 {
|
||||
ApiBlueprint,
|
||||
createApiRef,
|
||||
ErrorDisplay,
|
||||
NotFoundErrorPage,
|
||||
Progress,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* This is the old component API that has been replaced by the new SwappableComponentsApi.
|
||||
*
|
||||
* This backwards compatibility implementation exists to avoid breaking older plugins.$
|
||||
*
|
||||
* This was added for the 1.42 release, and can be removed in the future.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const LegacyComponentsApi = ApiBlueprint.make({
|
||||
name: 'components',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: createApiRef<{
|
||||
getComponent(ref: { id: string }): ComponentType<any>;
|
||||
}>({ id: 'core.components' }),
|
||||
deps: {},
|
||||
factory: () => ({
|
||||
getComponent(ref) {
|
||||
if (ref.id === 'core.components.progress') {
|
||||
return Progress;
|
||||
}
|
||||
if (ref.id === 'core.components.notFoundErrorPage') {
|
||||
return NotFoundErrorPage;
|
||||
}
|
||||
if (ref.id === 'core.components.errorBoundaryFallback') {
|
||||
return ErrorDisplay;
|
||||
}
|
||||
throw new Error(`No implementation found for component ref ${ref}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
ApiBlueprint,
|
||||
swappableComponentsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { DefaultSwappableComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi';
|
||||
import { DefaultSwappableComponentsApi } from '../apis/SwappableComponentsApi';
|
||||
|
||||
/**
|
||||
* Contains the shareable components installed into the app.
|
||||
|
||||
@@ -21,6 +21,7 @@ export { AppRoot } from './AppRoot';
|
||||
export { AppRoutes } from './AppRoutes';
|
||||
export { AppThemeApi, DarkTheme, LightTheme } from './AppThemeApi';
|
||||
export { SwappableComponentsApi } from './SwappableComponentsApi';
|
||||
export { LegacyComponentsApi } from './LegacyComponentsApi';
|
||||
export { IconsApi } from './IconsApi';
|
||||
export { FeatureFlagsApi } from './FeatureFlagsApi';
|
||||
export { TranslationsApi } from './TranslationsApi';
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
Progress,
|
||||
NotFoundErrorPage,
|
||||
ErrorBoundary,
|
||||
LegacyComponentsApi,
|
||||
} from './extensions';
|
||||
import { apis } from './defaultApis';
|
||||
|
||||
@@ -65,5 +66,6 @@ export const appPlugin = createFrontendPlugin({
|
||||
Progress,
|
||||
NotFoundErrorPage,
|
||||
ErrorBoundary,
|
||||
LegacyComponentsApi,
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user