Merge pull request #30750 from backstage/mob/component-refs-wth
`nfs(componentRefs)`: Reworking how Component Refs are defined and used
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-app': patch
|
||||
---
|
||||
|
||||
Default implementations of core components are now provided by this package.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
'@backstage/plugin-app': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead.
|
||||
|
||||
If you were overriding the `componentsApi` implementation, you can now use the new `SwappableComponentsApi` instead.
|
||||
|
||||
```ts
|
||||
// old
|
||||
appPlugin.getExtension('api:app/components').override(...)
|
||||
|
||||
// new
|
||||
appPlugin.getExtension('api:app/swappable-components').override(...)
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The component system has been overhauled to use `SwappableComponent` instead of `ComponentRef`. Several APIs have been removed and replaced:
|
||||
|
||||
- Removed: `createComponentRef`, `createComponentExtension`, `ComponentRef`, `ComponentsApi`, `componentsApiRef`, `useComponentRef`, `coreComponentRefs`
|
||||
- Added: `createSwappableComponent`, `SwappableComponentBlueprint`, `SwappableComponentRef`, `SwappableComponentsApi`, `swappableComponentsApiRef`
|
||||
|
||||
**BREAKING**: The default `componentRefs` and exported `Core*Props` have been removed and have replacement `SwappableComponents` and revised type names instead.
|
||||
|
||||
- The `errorBoundaryFallback` component and `CoreErrorBoundaryFallbackProps` type have been replaced with `ErrorDisplay` swappable component and `CoreErrorDisplayProps` respectively.
|
||||
- The `progress` component and `CoreProgressProps` type have been replaced with `Progress` swappable component and `ProgressProps` respectively.
|
||||
- The `notFoundErrorPage` component and `CoreNotFoundErrorPageProps` type have been replaced with `NotFoundErrorPage` swappable component and `NotFoundErrorPageProps` respectively.
|
||||
|
||||
**Migration for creating swappable components:**
|
||||
|
||||
```tsx
|
||||
// OLD: Using createComponentRef and createComponentExtension
|
||||
import {
|
||||
createComponentRef,
|
||||
createComponentExtension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
const myComponentRef = createComponentRef<{ title: string }>({
|
||||
id: 'my-plugin.my-component',
|
||||
});
|
||||
|
||||
const myComponentExtension = createComponentExtension({
|
||||
ref: myComponentRef,
|
||||
loader: {
|
||||
lazy: () => import('./MyComponent').then(m => m.MyComponent),
|
||||
},
|
||||
});
|
||||
|
||||
// NEW: Using createSwappableComponent and SwappableComponentBlueprint
|
||||
import {
|
||||
createSwappableComponent,
|
||||
SwappableComponentBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
const MySwappableComponent = createSwappableComponent({
|
||||
id: 'my-plugin.my-component',
|
||||
loader: () => import('./MyComponent').then(m => m.MyComponent),
|
||||
});
|
||||
|
||||
const myComponentExtension = SwappableComponentBlueprint.make({
|
||||
name: 'my-component',
|
||||
params: {
|
||||
component: MySwappableComponent,
|
||||
loader: () => import('./MyComponent').then(m => m.MyComponent),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Migration for using components:**
|
||||
|
||||
```tsx
|
||||
// OLD: Using ComponentsApi and useComponentRef
|
||||
import {
|
||||
useComponentRef,
|
||||
componentsApiRef,
|
||||
useApi,
|
||||
coreComponentRefs,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
const MyComponent = useComponentRef(myComponentRef);
|
||||
const ProgressComponent = useComponentRef(coreComponentRefs.progress);
|
||||
|
||||
|
||||
// NEW: Direct component usage
|
||||
import { Progress } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// Use directly as React Component
|
||||
<Progress />
|
||||
<MySwappableComponent title="Hello World" />
|
||||
```
|
||||
|
||||
**Migration for core component references:**
|
||||
|
||||
```tsx
|
||||
// OLD: Core component refs
|
||||
import { coreComponentRefs } from '@backstage/frontend-plugin-api';
|
||||
|
||||
coreComponentRefs.progress
|
||||
coreComponentRefs.notFoundErrorPage
|
||||
coreComponentRefs.errorBoundaryFallback
|
||||
|
||||
// NEW: Direct swappable component imports
|
||||
import { Progress, NotFoundErrorPage, ErrorDisplay } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// Use directly as React components
|
||||
<Progress />
|
||||
<NotFoundErrorPage />
|
||||
<ErrorDisplay plugin={plugin} error={error} resetError={resetError} />
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-compat-api': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `componentsApi` implementation has been removed from the plugin and replaced with the new `SwappableComponentsApi` instead. Which means that the `componentsApi` is not longer backwards compatible with legacy plugins.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Added a default implementation of the `SwappableComponentsApi` and removing the legacy `ComponentsApi` implementation
|
||||
@@ -467,6 +467,7 @@ Superfences
|
||||
superset
|
||||
supertype
|
||||
SVGs
|
||||
swappable
|
||||
talkdesk
|
||||
Talkdesk
|
||||
Tanzu
|
||||
|
||||
@@ -17,10 +17,6 @@ These are the [extension blueprints](../architecture/23-extension-blueprints.md)
|
||||
|
||||
An API extension is used to add or override [Utility API factories](../utility-apis/01-index.md) in the app. They are commonly used by plugins for both internal and shared APIs. There are also many built-in Api extensions provided by the framework that you are able to override.
|
||||
|
||||
### Component - [Reference](../../reference/frontend-plugin-api.createcomponentextension.md)
|
||||
|
||||
Components extensions are used to override the component associated with a component reference throughout the app. This uses an extension creator function rather than a blueprint, but will likely be migrated to a blueprint in the future.
|
||||
|
||||
### NavItem - [Reference](../../reference/frontend-plugin-api.navitemblueprint.md)
|
||||
|
||||
Navigation item extensions are used to provide menu items that link to different parts of the app. By default nav items are attached to the app nav extension, which by default is rendered as the left sidebar in the app.
|
||||
@@ -33,6 +29,10 @@ Page extensions provide content for a particular route in the app. By default pa
|
||||
|
||||
Sign-in page extension have a single purpose - to implement a custom sign-in page. They are always attached to the app root extension and are rendered before the rest of the app until the user is signed in.
|
||||
|
||||
### SwappableComponent - [Reference](../../reference/frontend-plugin-api.swappablecomponentblueprint.md)
|
||||
|
||||
Swappable Components are extensions that are used to replace the implementations of components in the app and plugins.
|
||||
|
||||
### Theme - [Reference](../../reference/frontend-plugin-api.themeblueprint.md)
|
||||
|
||||
Theme extensions provide custom themes for the app. They are always attached to the app extension and you can have any number of themes extensions installed in an app at once, letting the user choose which theme to use.
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createComponentExtension,
|
||||
coreComponentRefs,
|
||||
SwappableComponentBlueprint,
|
||||
NotFoundErrorPage,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import Box from '@material-ui/core/Box';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { Button } from '@backstage/core-components';
|
||||
|
||||
export function CustomNotFoundErrorPage() {
|
||||
function CustomNotFoundErrorPage() {
|
||||
return (
|
||||
<Box
|
||||
component="article"
|
||||
@@ -50,8 +50,11 @@ export function CustomNotFoundErrorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default createComponentExtension({
|
||||
export default SwappableComponentBlueprint.make({
|
||||
name: 'not-found-error-page',
|
||||
ref: coreComponentRefs.notFoundErrorPage,
|
||||
loader: { sync: () => CustomNotFoundErrorPage },
|
||||
params: define =>
|
||||
define({
|
||||
component: NotFoundErrorPage,
|
||||
loader: () => CustomNotFoundErrorPage,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
createFrontendPlugin as createNewPlugin,
|
||||
FrontendPlugin as NewFrontendPlugin,
|
||||
appTreeApiRef,
|
||||
componentsApiRef,
|
||||
coreComponentRefs,
|
||||
iconsApiRef,
|
||||
useApi,
|
||||
routeResolutionApiRef,
|
||||
ErrorDisplay,
|
||||
NotFoundErrorPage,
|
||||
Progress,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
AppComponents,
|
||||
@@ -92,7 +93,6 @@ function toNewPlugin(plugin: LegacyBackstagePlugin): NewFrontendPlugin {
|
||||
// Recreates the old AppContext APIs using the various new APIs that replaced it
|
||||
function LegacyAppContextProvider(props: { children: ReactNode }) {
|
||||
const appTreeApi = useApi(appTreeApiRef);
|
||||
const componentsApi = useApi(componentsApiRef);
|
||||
const iconsApi = useApi(iconsApiRef);
|
||||
|
||||
const appContext = useMemo(() => {
|
||||
@@ -100,15 +100,9 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
|
||||
|
||||
let gatheredPlugins: LegacyBackstagePlugin[] | undefined = undefined;
|
||||
|
||||
const ErrorBoundaryFallback = componentsApi.getComponent(
|
||||
coreComponentRefs.errorBoundaryFallback,
|
||||
);
|
||||
const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] =
|
||||
({ plugin, ...rest }) => (
|
||||
<ErrorBoundaryFallback
|
||||
{...rest}
|
||||
plugin={plugin && toNewPlugin(plugin)}
|
||||
/>
|
||||
<ErrorDisplay {...rest} plugin={plugin && toNewPlugin(plugin)} />
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -141,15 +135,13 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
|
||||
|
||||
getComponents(): AppComponents {
|
||||
return {
|
||||
NotFoundErrorPage: componentsApi.getComponent(
|
||||
coreComponentRefs.notFoundErrorPage,
|
||||
),
|
||||
NotFoundErrorPage: NotFoundErrorPage,
|
||||
BootErrorPage() {
|
||||
throw new Error(
|
||||
'The BootErrorPage app component should not be accessed by plugins',
|
||||
);
|
||||
},
|
||||
Progress: componentsApi.getComponent(coreComponentRefs.progress),
|
||||
Progress: Progress,
|
||||
Router() {
|
||||
throw new Error(
|
||||
'The Router app component should not be accessed by plugins',
|
||||
@@ -159,7 +151,7 @@ function LegacyAppContextProvider(props: { children: ReactNode }) {
|
||||
};
|
||||
},
|
||||
};
|
||||
}, [appTreeApi, componentsApi, iconsApi]);
|
||||
}, [appTreeApi, iconsApi]);
|
||||
|
||||
return (
|
||||
<AppContextProvider appContext={appContext}>
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
AnyRouteRefParams,
|
||||
ComponentRef,
|
||||
ComponentsApi,
|
||||
CoreErrorBoundaryFallbackProps,
|
||||
CoreNotFoundErrorPageProps,
|
||||
CoreProgressProps,
|
||||
SwappableComponentRef,
|
||||
SwappableComponentsApi,
|
||||
ErrorDisplayProps,
|
||||
NotFoundErrorPageProps,
|
||||
ProgressProps,
|
||||
ExternalRouteRef,
|
||||
IconComponent,
|
||||
IconsApi,
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
RouteRef,
|
||||
RouteResolutionApi,
|
||||
SubRouteRef,
|
||||
componentsApiRef,
|
||||
coreComponentRefs,
|
||||
swappableComponentsApiRef,
|
||||
iconsApiRef,
|
||||
routeResolutionApiRef,
|
||||
Progress,
|
||||
NotFoundErrorPage,
|
||||
ErrorDisplay,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
@@ -49,14 +51,14 @@ import { useVersionedContext } from '@backstage/version-bridge';
|
||||
import { type RouteResolver } from '../../../core-plugin-api/src/routing/useRouteRef';
|
||||
import { convertLegacyRouteRef } from '../convertLegacyRouteRef';
|
||||
|
||||
class CompatComponentsApi implements ComponentsApi {
|
||||
readonly #Progress: ComponentType<CoreProgressProps>;
|
||||
readonly #NotFoundErrorPage: ComponentType<CoreNotFoundErrorPageProps>;
|
||||
readonly #ErrorBoundaryFallback: ComponentType<CoreErrorBoundaryFallbackProps>;
|
||||
class CompatComponentsApi implements SwappableComponentsApi {
|
||||
readonly #Progress: ComponentType<ProgressProps>;
|
||||
readonly #NotFoundErrorPage: ComponentType<NotFoundErrorPageProps>;
|
||||
readonly #ErrorBoundaryFallback: ComponentType<ErrorDisplayProps>;
|
||||
|
||||
constructor(app: AppContext) {
|
||||
const components = app.getComponents();
|
||||
const ErrorBoundaryFallback = (props: CoreErrorBoundaryFallbackProps) => (
|
||||
const ErrorBoundaryFallback = (props: ErrorDisplayProps) => (
|
||||
<components.ErrorBoundaryFallback
|
||||
{...props}
|
||||
plugin={props.plugin && toLegacyPlugin(props.plugin)}
|
||||
@@ -67,14 +69,21 @@ class CompatComponentsApi implements ComponentsApi {
|
||||
this.#ErrorBoundaryFallback = ErrorBoundaryFallback;
|
||||
}
|
||||
|
||||
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T> {
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
): (props: TInnerComponentProps) => JSX.Element | null {
|
||||
switch (ref.id) {
|
||||
case coreComponentRefs.progress.id:
|
||||
return this.#Progress as ComponentType<any>;
|
||||
case coreComponentRefs.notFoundErrorPage.id:
|
||||
return this.#NotFoundErrorPage as ComponentType<any>;
|
||||
case coreComponentRefs.errorBoundaryFallback.id:
|
||||
return this.#ErrorBoundaryFallback as ComponentType<any>;
|
||||
case Progress.ref.id:
|
||||
return this.#Progress as (props: object) => JSX.Element | null;
|
||||
case NotFoundErrorPage.ref.id:
|
||||
return this.#NotFoundErrorPage as (props: object) => JSX.Element | null;
|
||||
case ErrorDisplay.ref.id:
|
||||
return this.#ErrorBoundaryFallback as (
|
||||
props: object,
|
||||
) => JSX.Element | null;
|
||||
default:
|
||||
throw new Error(
|
||||
`No backwards compatible component is available for ref '${ref.id}'`,
|
||||
@@ -119,7 +128,7 @@ class CompatRouteResolutionApi implements RouteResolutionApi {
|
||||
}
|
||||
|
||||
class ForwardsCompatApis implements ApiHolder {
|
||||
readonly #componentsApi: ComponentsApi;
|
||||
readonly #componentsApi: SwappableComponentsApi;
|
||||
readonly #iconsApi: IconsApi;
|
||||
readonly #routeResolutionApi: RouteResolutionApi;
|
||||
|
||||
@@ -130,7 +139,7 @@ class ForwardsCompatApis implements ApiHolder {
|
||||
}
|
||||
|
||||
get<T>(ref: ApiRef<any>): T | undefined {
|
||||
if (ref.id === componentsApiRef.id) {
|
||||
if (ref.id === swappableComponentsApiRef.id) {
|
||||
return this.#componentsApi as T;
|
||||
} else if (ref.id === iconsApiRef.id) {
|
||||
return this.#iconsApi as T;
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
componentsApiRef,
|
||||
coreComponentRefs,
|
||||
swappableComponentsApiRef,
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
iconsApiRef,
|
||||
useRouteRef as useNewRouteRef,
|
||||
createRouteRef as createNewRouteRef,
|
||||
useApi,
|
||||
NotFoundErrorPage,
|
||||
ErrorDisplay,
|
||||
Progress,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createExtensionTester,
|
||||
@@ -97,13 +99,19 @@ describe('BackwardsCompatProvider', () => {
|
||||
|
||||
describe('ForwardsCompatProvider', () => {
|
||||
it('should convert the app context', async () => {
|
||||
const defaultComponentRefs = {
|
||||
progress: Progress.ref,
|
||||
notFoundErrorPage: NotFoundErrorPage.ref,
|
||||
errorDisplay: ErrorDisplay.ref,
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const components = useApi(componentsApiRef);
|
||||
const components = useApi(swappableComponentsApiRef);
|
||||
const icons = useApi(iconsApiRef);
|
||||
return (
|
||||
<div data-testid="ctx">
|
||||
components:{' '}
|
||||
{Object.entries(coreComponentRefs)
|
||||
{Object.entries(defaultComponentRefs)
|
||||
.map(
|
||||
([name, ref]) =>
|
||||
`${name}=${Boolean(components.getComponent(ref))}`,
|
||||
@@ -118,7 +126,7 @@ describe('ForwardsCompatProvider', () => {
|
||||
await renderInOldTestApp(compatWrapper(<Component />));
|
||||
|
||||
expect(screen.getByTestId('ctx').textContent).toMatchInlineSnapshot(`
|
||||
"components: progress=true, notFoundErrorPage=true, errorBoundaryFallback=true
|
||||
"components: progress=true, notFoundErrorPage=true, errorDisplay=true
|
||||
icons: kind:api, kind:component, kind:domain, kind:group, kind:location, kind:system, kind:user, kind:resource, kind:template, brokenImage, catalog, scaffolder, techdocs, search, chat, dashboard, docs, email, github, group, help, user, warning, star, unstarred"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import {
|
||||
SwappableComponentBlueprint,
|
||||
ApiBlueprint,
|
||||
coreComponentRefs,
|
||||
CoreErrorBoundaryFallbackProps,
|
||||
createComponentExtension,
|
||||
ErrorDisplayProps,
|
||||
createExtension,
|
||||
createFrontendModule,
|
||||
ExtensionDefinition,
|
||||
@@ -28,6 +27,9 @@ import {
|
||||
RouterBlueprint,
|
||||
SignInPageBlueprint,
|
||||
ThemeBlueprint,
|
||||
ErrorDisplay as SwappableErrorDisplay,
|
||||
NotFoundErrorPage as SwappableNotFoundErrorPage,
|
||||
Progress as SwappableProgress,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
@@ -154,36 +156,45 @@ export function convertLegacyAppOptions(
|
||||
}
|
||||
if (Progress) {
|
||||
extensions.push(
|
||||
createComponentExtension({
|
||||
ref: coreComponentRefs.progress,
|
||||
loader: { sync: () => componentCompatWrapper(Progress) },
|
||||
SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableProgress,
|
||||
loader: () => componentCompatWrapper(Progress),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (NotFoundErrorPage) {
|
||||
extensions.push(
|
||||
createComponentExtension({
|
||||
ref: coreComponentRefs.notFoundErrorPage,
|
||||
loader: { sync: () => componentCompatWrapper(NotFoundErrorPage) },
|
||||
SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableNotFoundErrorPage,
|
||||
loader: () => componentCompatWrapper(NotFoundErrorPage),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (ErrorBoundaryFallback) {
|
||||
const WrappedErrorBoundaryFallback = (
|
||||
props: CoreErrorBoundaryFallbackProps,
|
||||
) =>
|
||||
const WrappedErrorBoundaryFallback = (props: ErrorDisplayProps) =>
|
||||
compatWrapper(
|
||||
<ErrorBoundaryFallback
|
||||
{...props}
|
||||
plugin={props.plugin && toLegacyPlugin(props.plugin)}
|
||||
/>,
|
||||
);
|
||||
|
||||
extensions.push(
|
||||
createComponentExtension({
|
||||
ref: coreComponentRefs.errorBoundaryFallback,
|
||||
loader: {
|
||||
sync: () => componentCompatWrapper(WrappedErrorBoundaryFallback),
|
||||
},
|
||||
SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableErrorDisplay,
|
||||
loader: () =>
|
||||
componentCompatWrapper(WrappedErrorBoundaryFallback),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
* 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 { createComponentRef } from '@backstage/frontend-plugin-api';
|
||||
import { DefaultComponentsApi } from './DefaultComponentsApi';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
const testRefA = createComponentRef({ id: 'test.a' });
|
||||
const testRefB1 = createComponentRef({ id: 'test.b' });
|
||||
const testRefB2 = createComponentRef({ id: 'test.b' });
|
||||
|
||||
describe('DefaultComponentsApi', () => {
|
||||
it('should provide components', () => {
|
||||
const api = DefaultComponentsApi.fromComponents([
|
||||
{
|
||||
ref: testRefA,
|
||||
impl: () => <div>test.a</div>,
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentA = api.getComponent(testRefA);
|
||||
render(<ComponentA />);
|
||||
|
||||
expect(screen.getByText('test.a')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should key extension refs by ID', () => {
|
||||
const api = DefaultComponentsApi.fromComponents([
|
||||
{
|
||||
ref: testRefB1,
|
||||
impl: () => <div>test.b</div>,
|
||||
},
|
||||
]);
|
||||
|
||||
const ComponentB1 = api.getComponent(testRefB1);
|
||||
const ComponentB2 = api.getComponent(testRefB2);
|
||||
|
||||
expect(ComponentB1).toBe(ComponentB2);
|
||||
|
||||
render(<ComponentB2 />);
|
||||
|
||||
expect(screen.getByText('test.b')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* 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 { ComponentType } from 'react';
|
||||
import {
|
||||
ComponentRef,
|
||||
ComponentsApi,
|
||||
createComponentExtension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Implementation for the {@linkComponentApi}
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class DefaultComponentsApi implements ComponentsApi {
|
||||
#components: Map<string, ComponentType<any>>;
|
||||
|
||||
static fromComponents(
|
||||
components: Array<typeof createComponentExtension.componentDataRef.T>,
|
||||
) {
|
||||
return new DefaultComponentsApi(
|
||||
new Map(components.map(entry => [entry.ref.id, entry.impl])),
|
||||
);
|
||||
}
|
||||
|
||||
constructor(components: Map<string, any>) {
|
||||
this.#components = components;
|
||||
}
|
||||
|
||||
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T> {
|
||||
const impl = this.#components.get(ref.id);
|
||||
if (!impl) {
|
||||
throw new Error(`No implementation found for component ref ${ref}`);
|
||||
}
|
||||
return impl;
|
||||
}
|
||||
}
|
||||
+264
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+74
@@ -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} />;
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DefaultComponentsApi } from './DefaultComponentsApi';
|
||||
export { DefaultSwappableComponentsApi } from './DefaultSwappableComponentsApi';
|
||||
@@ -366,13 +366,13 @@ describe('createApp', () => {
|
||||
<theme:app/light out=[core.theme.theme] />
|
||||
]
|
||||
</api:app/app-theme>
|
||||
<api:app/components out=[core.api.factory]>
|
||||
<api:app/swappable-components out=[core.api.factory]>
|
||||
components [
|
||||
<component:app/core.components.progress out=[core.component.component] />
|
||||
<component:app/core.components.notFoundErrorPage out=[core.component.component] />
|
||||
<component:app/core.components.errorBoundaryFallback out=[core.component.component] />
|
||||
<component:app/core.components.progress out=[core.swappableComponent] />
|
||||
<component:app/core.components.notFoundErrorPage out=[core.swappableComponent] />
|
||||
<component:app/core.components.errorBoundary out=[core.swappableComponent] />
|
||||
]
|
||||
</api:app/components>
|
||||
</api:app/swappable-components>
|
||||
<api:app/icons out=[core.api.factory] />
|
||||
<api:app/feature-flags out=[core.api.factory] />
|
||||
<api:app/translations out=[core.api.factory] />
|
||||
|
||||
+13
-8
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* 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.
|
||||
@@ -14,12 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createComponentRef } from './createComponentRef';
|
||||
import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
describe('createComponentRef', () => {
|
||||
it('can be created and read', () => {
|
||||
const ref = createComponentRef({ id: 'foo' });
|
||||
expect(ref.id).toBe('foo');
|
||||
expect(String(ref)).toBe('ComponentRef{id=foo}');
|
||||
});
|
||||
export const OpaqueSwappableComponentRef = OpaqueType.create<{
|
||||
public: SwappableComponentRef;
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
readonly transformProps?: (props: object) => object;
|
||||
readonly defaultComponent: (props: object) => JSX.Element | null;
|
||||
};
|
||||
}>({
|
||||
versions: ['v1'],
|
||||
type: '@backstage/SwappableComponentRef',
|
||||
});
|
||||
@@ -15,5 +15,6 @@
|
||||
*/
|
||||
|
||||
export { createExtensionDataContainer } from './createExtensionDataContainer';
|
||||
export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
|
||||
export { OpaqueExtensionDefinition } from './InternalExtensionDefinition';
|
||||
export { OpaqueFrontendPlugin } from './InternalFrontendPlugin';
|
||||
|
||||
@@ -350,21 +350,6 @@ export { bitbucketAuthApiRef };
|
||||
|
||||
export { bitbucketServerAuthApiRef };
|
||||
|
||||
// @public (undocumented)
|
||||
export type ComponentRef<T extends {} = {}> = {
|
||||
id: string;
|
||||
T: T;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface ComponentsApi {
|
||||
// (undocumented)
|
||||
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const componentsApiRef: ApiRef<ComponentsApi>;
|
||||
|
||||
export { ConfigApi };
|
||||
|
||||
export { configApiRef };
|
||||
@@ -389,20 +374,6 @@ export interface ConfigurableExtensionDataRef<
|
||||
>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const coreComponentRefs: {
|
||||
progress: ComponentRef<CoreProgressProps>;
|
||||
notFoundErrorPage: ComponentRef<CoreNotFoundErrorPageProps>;
|
||||
errorBoundaryFallback: ComponentRef<CoreErrorBoundaryFallbackProps>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CoreErrorBoundaryFallbackProps = {
|
||||
plugin?: FrontendPlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const coreExtensionData: {
|
||||
reactElement: ConfigurableExtensionDataRef<
|
||||
@@ -418,73 +389,10 @@ export const coreExtensionData: {
|
||||
>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CoreNotFoundErrorPageProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type CoreProgressProps = {};
|
||||
|
||||
export { createApiFactory };
|
||||
|
||||
export { createApiRef };
|
||||
|
||||
// @public (undocumented)
|
||||
export function createComponentExtension<TProps extends {}>(options: {
|
||||
ref: ComponentRef<TProps>;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
loader:
|
||||
| {
|
||||
lazy: () => Promise<ComponentType<TProps>>;
|
||||
}
|
||||
| {
|
||||
sync: () => ComponentType<TProps>;
|
||||
};
|
||||
}): ExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<
|
||||
{
|
||||
ref: ComponentRef;
|
||||
impl: ComponentType;
|
||||
},
|
||||
'core.component.component',
|
||||
{}
|
||||
>;
|
||||
inputs: {
|
||||
[x: string]: ExtensionInput<
|
||||
ExtensionDataRef,
|
||||
{
|
||||
optional: boolean;
|
||||
singleton: boolean;
|
||||
}
|
||||
>;
|
||||
};
|
||||
params: never;
|
||||
kind: 'component';
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export namespace createComponentExtension {
|
||||
const // (undocumented)
|
||||
componentDataRef: ConfigurableExtensionDataRef<
|
||||
{
|
||||
ref: ComponentRef;
|
||||
impl: ComponentType;
|
||||
},
|
||||
'core.component.component',
|
||||
{}
|
||||
>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export function createComponentRef<T extends {} = {}>(options: {
|
||||
id: string;
|
||||
}): ComponentRef<T>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createExtension<
|
||||
UOutput extends ExtensionDataRef,
|
||||
@@ -849,6 +757,31 @@ export function createSubRouteRef<
|
||||
parent: RouteRef<ParentParams>;
|
||||
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
|
||||
|
||||
// @public
|
||||
export function createSwappableComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
options: CreateSwappableComponentOptions<
|
||||
TInnerComponentProps,
|
||||
TExternalComponentProps
|
||||
>,
|
||||
): ((props: TExternalComponentProps) => JSX.Element | null) & {
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateSwappableComponentOptions<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
> = {
|
||||
id: string;
|
||||
loader?:
|
||||
| (() => (props: TInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
|
||||
transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
|
||||
};
|
||||
|
||||
export { createTranslationMessages };
|
||||
|
||||
export { createTranslationRef };
|
||||
@@ -899,6 +832,20 @@ export { ErrorApiErrorContext };
|
||||
|
||||
export { errorApiRef };
|
||||
|
||||
// @public (undocumented)
|
||||
export const ErrorDisplay: ((
|
||||
props: ErrorDisplayProps,
|
||||
) => JSX.Element | null) & {
|
||||
ref: SwappableComponentRef<ErrorDisplayProps, ErrorDisplayProps>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ErrorDisplayProps = {
|
||||
plugin?: FrontendPlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
// (undocumented)
|
||||
@@ -1554,6 +1501,18 @@ export const NavItemBlueprint: ExtensionBlueprint<{
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const NotFoundErrorPage: ((
|
||||
props: NotFoundErrorPageProps,
|
||||
) => JSX.Element | null) & {
|
||||
ref: SwappableComponentRef<NotFoundErrorPageProps, NotFoundErrorPageProps>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type NotFoundErrorPageProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export { OAuthApi };
|
||||
|
||||
export { OAuthRequestApi };
|
||||
@@ -1638,6 +1597,14 @@ export { ProfileInfo };
|
||||
|
||||
export { ProfileInfoApi };
|
||||
|
||||
// @public (undocumented)
|
||||
export const Progress: ((props: ProgressProps) => JSX.Element | null) & {
|
||||
ref: SwappableComponentRef<ProgressProps, ProgressProps>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ProgressProps = {};
|
||||
|
||||
// @public
|
||||
export type ResolvedExtensionInput<
|
||||
TExtensionInput extends ExtensionInput<any, any>,
|
||||
@@ -1830,6 +1797,90 @@ export interface SubRouteRef<
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const SwappableComponentBlueprint: ExtensionBlueprint<{
|
||||
kind: 'component';
|
||||
params: <Ref extends SwappableComponentRef<any>>(params: {
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
|
||||
: never;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
|
||||
: never;
|
||||
}>;
|
||||
output: ExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
config: {};
|
||||
configInput: {};
|
||||
dataRefs: {
|
||||
component: ConfigurableExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SwappableComponentRef<
|
||||
TInnerComponentProps extends {} = {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
> = {
|
||||
id: string;
|
||||
TProps: TInnerComponentProps;
|
||||
TExternalProps: TExternalComponentProps;
|
||||
$$type: '@backstage/SwappableComponentRef';
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface SwappableComponentsApi {
|
||||
// (undocumented)
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
): (props: TInnerComponentProps) => JSX.Element | null;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const swappableComponentsApiRef: ApiRef<SwappableComponentsApi>;
|
||||
|
||||
// @public
|
||||
export const ThemeBlueprint: ExtensionBlueprint<{
|
||||
kind: 'theme';
|
||||
@@ -1906,11 +1957,6 @@ export { useApiHolder };
|
||||
// @public
|
||||
export function useAppNode(): AppNode | undefined;
|
||||
|
||||
// @public
|
||||
export function useComponentRef<T extends {}>(
|
||||
ref: ComponentRef<T>,
|
||||
): ComponentType<T>;
|
||||
|
||||
// @public
|
||||
export function useRouteRef<TParams extends AnyRouteRefParams>(
|
||||
routeRef:
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* 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 { ComponentType } from 'react';
|
||||
import { createApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { ComponentRef } from '../../components';
|
||||
|
||||
/**
|
||||
* API for looking up components based on component refs.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ComponentsApi {
|
||||
// TODO: Should component refs also provide the default implementation so that we're guaranteed to get a component?
|
||||
getComponent<T extends {}>(ref: ComponentRef<T>): ComponentType<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ApiRef` of {@link ComponentsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const componentsApiRef = createApiRef<ComponentsApi>({
|
||||
id: 'core.components',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Returns the component associated with the given ref.
|
||||
*/
|
||||
export function useComponentRef<T extends {}>(
|
||||
ref: ComponentRef<T>,
|
||||
): ComponentType<T> {
|
||||
const componentsApi = useApi(componentsApiRef);
|
||||
return componentsApi.getComponent<T>(ref);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 } from '../../components';
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* API for looking up components based on component refs.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SwappableComponentsApi {
|
||||
getComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
|
||||
): (props: TInnerComponentProps) => JSX.Element | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ApiRef` of {@link SwappableComponentsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const swappableComponentsApiRef = createApiRef<SwappableComponentsApi>({
|
||||
id: 'core.swappable-components',
|
||||
});
|
||||
@@ -34,7 +34,7 @@ export * from './auth';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ComponentsApi';
|
||||
export * from './SwappableComponentsApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './DiscoveryApi';
|
||||
export * from './ErrorApi';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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 { renderInTestApp } from '@backstage/frontend-test-utils';
|
||||
import { createSwappableComponent } from '../components';
|
||||
import { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
|
||||
import { PageBlueprint } from './PageBlueprint';
|
||||
import { waitFor, screen } from '@testing-library/react';
|
||||
|
||||
describe('SwappableComponentBlueprint', () => {
|
||||
it('should allow defining a component override for a component ref', () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
|
||||
});
|
||||
|
||||
const extension = SwappableComponentBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
component: Component,
|
||||
loader: () => props => {
|
||||
// @ts-expect-error
|
||||
const t: number = props.hello;
|
||||
|
||||
return <div>Override {props.hello}</div>;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render default component refs in the app', async () => {
|
||||
const TestComponent = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<div />, {
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
// todo(blam): there's a bug that this path cannot be `/`?
|
||||
path: '/test',
|
||||
loader: async () => <TestComponent hello="test!" />,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
initialRouteEntries: ['/test'],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should render a component ref without a default implementation', async () => {
|
||||
const TestComponent = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
});
|
||||
|
||||
renderInTestApp(<div />, {
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
path: '/test',
|
||||
loader: async () => <TestComponent />,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
initialRouteEntries: ['/test'],
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('test.component')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a component ref with an async loader implementation', async () => {
|
||||
const TestComponent = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
loader: async () => (props: { hello: string }) =>
|
||||
<div>{props.hello}</div>,
|
||||
});
|
||||
|
||||
renderInTestApp(<div />, {
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
// todo(blam): there's a bug that this path cannot be `/`?
|
||||
path: '/test',
|
||||
loader: async () => <TestComponent hello="test!" />,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
initialRouteEntries: ['/test'],
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should render a component ref with an async loader implementation and prop transform', async () => {
|
||||
const TestComponent = createSwappableComponent({
|
||||
id: 'test.component',
|
||||
loader: async () => (props: { hello: string }) =>
|
||||
<div>{props.hello}</div>,
|
||||
transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
|
||||
});
|
||||
|
||||
renderInTestApp(<div />, {
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: define =>
|
||||
define({
|
||||
// todo(blam): there's a bug that this path cannot be `/`?
|
||||
path: '/test',
|
||||
loader: async () => <TestComponent hello="test!" />,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
initialRouteEntries: ['/test'],
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('tr test!')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { SwappableComponentRef } from '../components';
|
||||
import {
|
||||
createExtensionBlueprint,
|
||||
createExtensionBlueprintParams,
|
||||
createExtensionDataRef,
|
||||
} from '../wiring';
|
||||
|
||||
export const componentDataRef = createExtensionDataRef<{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
}>().with({ id: 'core.swappableComponent' });
|
||||
|
||||
/**
|
||||
* Blueprint for creating swappable components from a SwappableComponentRef and a loader
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SwappableComponentBlueprint = createExtensionBlueprint({
|
||||
kind: 'component',
|
||||
attachTo: { id: 'api:app/swappable-components', input: 'components' },
|
||||
output: [componentDataRef],
|
||||
dataRefs: {
|
||||
component: componentDataRef,
|
||||
},
|
||||
defineParams<Ref extends SwappableComponentRef<any>>(params: {
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? { ref: Ref } & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<infer IInnerComponentProps, any>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
|
||||
: never;
|
||||
}) {
|
||||
return createExtensionBlueprintParams(params);
|
||||
},
|
||||
factory: params => [
|
||||
componentDataRef({
|
||||
ref: params.component.ref,
|
||||
loader: params.loader,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -33,3 +33,4 @@ export { RouterBlueprint } from './RouterBlueprint';
|
||||
export { SignInPageBlueprint } from './SignInPageBlueprint';
|
||||
export { ThemeBlueprint } from './ThemeBlueprint';
|
||||
export { TranslationBlueprint } from './TranslationBlueprint';
|
||||
export { SwappableComponentBlueprint } from './SwappableComponentBlueprint';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 {
|
||||
ErrorDisplayProps,
|
||||
NotFoundErrorPageProps,
|
||||
ProgressProps,
|
||||
} from '../types';
|
||||
import { createSwappableComponent } from './createSwappableComponent';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const Progress = createSwappableComponent<ProgressProps>({
|
||||
id: 'core.components.progress',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const NotFoundErrorPage =
|
||||
createSwappableComponent<NotFoundErrorPageProps>({
|
||||
id: 'core.components.notFoundErrorPage',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const ErrorDisplay = createSwappableComponent<ErrorDisplayProps>({
|
||||
id: 'core.components.errorDisplay',
|
||||
loader: () => props =>
|
||||
<div data-testid="core.components.errorDisplay">{props.error.message}</div>,
|
||||
});
|
||||
@@ -14,13 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, ComponentType, PropsWithChildren } from 'react';
|
||||
import { Component, PropsWithChildren } from 'react';
|
||||
import { FrontendPlugin } from '../wiring';
|
||||
import { CoreErrorBoundaryFallbackProps } from '../types';
|
||||
import { ErrorDisplay } from './DefaultSwappableComponents';
|
||||
|
||||
type ErrorBoundaryProps = PropsWithChildren<{
|
||||
plugin?: FrontendPlugin;
|
||||
Fallback: ComponentType<CoreErrorBoundaryFallbackProps>;
|
||||
}>;
|
||||
type ErrorBoundaryState = { error?: Error };
|
||||
|
||||
@@ -41,13 +40,15 @@ export class ErrorBoundary extends Component<
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
const { plugin, children, Fallback } = this.props;
|
||||
const { plugin, children } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Fallback
|
||||
<ErrorDisplay
|
||||
// todo: do we want to just use useAppNode hook in the ErrorDisplay instead?
|
||||
plugin={plugin}
|
||||
error={error}
|
||||
// todo: probably change this to onResetError
|
||||
resetError={this.handleErrorReset}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,10 @@ import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
|
||||
import { AppNode, useComponentRef } from '../apis';
|
||||
import { coreComponentRefs } from './coreComponentRefs';
|
||||
import { AppNode } from '../apis';
|
||||
import { coreExtensionData } from '../wiring';
|
||||
import { AppNodeProvider } from './AppNodeProvider';
|
||||
import { Progress } from './DefaultSwappableComponents';
|
||||
|
||||
type RouteTrackerProps = PropsWithChildren<{
|
||||
enabled?: boolean;
|
||||
@@ -66,8 +66,6 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
|
||||
);
|
||||
|
||||
const plugin = node.spec.plugin;
|
||||
const Progress = useComponentRef(coreComponentRefs.progress);
|
||||
const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
|
||||
|
||||
// Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight
|
||||
const attributes = {
|
||||
@@ -78,7 +76,7 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
|
||||
return (
|
||||
<AppNodeProvider node={node}>
|
||||
<Suspense fallback={<Progress />}>
|
||||
<ErrorBoundary plugin={plugin} Fallback={fallback}>
|
||||
<ErrorBoundary plugin={plugin}>
|
||||
<AnalyticsContext attributes={attributes}>
|
||||
<RouteTracker enabled={hasRoutePathOutput}>{children}</RouteTracker>
|
||||
</AnalyticsContext>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
CoreErrorBoundaryFallbackProps,
|
||||
CoreNotFoundErrorPageProps,
|
||||
CoreProgressProps,
|
||||
} from '../types';
|
||||
import { createComponentRef } from './createComponentRef';
|
||||
|
||||
const coreProgressComponentRef = createComponentRef<CoreProgressProps>({
|
||||
id: 'core.components.progress',
|
||||
});
|
||||
|
||||
const coreNotFoundErrorPageComponentRef =
|
||||
createComponentRef<CoreNotFoundErrorPageProps>({
|
||||
id: 'core.components.notFoundErrorPage',
|
||||
});
|
||||
|
||||
const coreErrorBoundaryFallbackComponentRef =
|
||||
createComponentRef<CoreErrorBoundaryFallbackProps>({
|
||||
id: 'core.components.errorBoundaryFallback',
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export const coreComponentRefs = {
|
||||
progress: coreProgressComponentRef,
|
||||
notFoundErrorPage: coreNotFoundErrorPageComponentRef,
|
||||
errorBoundaryFallback: coreErrorBoundaryFallbackComponentRef,
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** @public */
|
||||
export type ComponentRef<T extends {} = {}> = {
|
||||
id: string;
|
||||
T: T;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export function createComponentRef<T extends {} = {}>(options: {
|
||||
id: string;
|
||||
}): ComponentRef<T> {
|
||||
const { id } = options;
|
||||
return {
|
||||
id,
|
||||
toString() {
|
||||
return `ComponentRef{id=${id}}`;
|
||||
},
|
||||
} as ComponentRef<T>;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import { createSwappableComponent } from './createSwappableComponent';
|
||||
|
||||
describe('createSwappableComponent', () => {
|
||||
it('can be created and read', () => {
|
||||
const { ref } = createSwappableComponent({ id: 'foo' });
|
||||
expect(ref.id).toBe('foo');
|
||||
expect(String(ref)).toBe('SwappableComponentRef{id=foo}');
|
||||
});
|
||||
|
||||
it('should allow defining a default component implementation', () => {
|
||||
const Test = () => <div>test</div>;
|
||||
|
||||
createSwappableComponent<{ foo: string }, { bar: string }>({
|
||||
id: 'foo',
|
||||
loader:
|
||||
() =>
|
||||
({ foo }) =>
|
||||
<Test key={foo} />,
|
||||
});
|
||||
|
||||
createSwappableComponent<{ foo: string }, { bar: string }>({
|
||||
id: 'foo',
|
||||
loader:
|
||||
async () =>
|
||||
({ foo }) =>
|
||||
<Test key={foo} />,
|
||||
});
|
||||
|
||||
createSwappableComponent<{ foo: string }, { bar: string }>({
|
||||
id: 'foo',
|
||||
});
|
||||
|
||||
expect(Test).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow transformings props', () => {
|
||||
createSwappableComponent<{ foo: string }, { bar: string }>({
|
||||
id: 'foo',
|
||||
transformProps: props => ({ foo: props.bar }),
|
||||
});
|
||||
|
||||
createSwappableComponent<{ foo: string }, { bar: string }>({
|
||||
id: 'foo',
|
||||
// @ts-expect-error - this should be an error as foo is not a string
|
||||
transformProps: props => ({ foo: 1 }),
|
||||
});
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
it('should create a component from a ref for sync component', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
loader: () => (props: { name: string }) => {
|
||||
return <div data-testid="test">{props.name}</div>;
|
||||
},
|
||||
transformProps: (props: { id: string }) => ({
|
||||
name: props.id,
|
||||
}),
|
||||
});
|
||||
|
||||
render(<Component id="test" />);
|
||||
|
||||
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
|
||||
'test',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a fallback when theres no default implementation provided', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
});
|
||||
|
||||
render(<Component />);
|
||||
await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map props from external to internal', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
transformProps: (props: { name: string }) => ({
|
||||
uppercase: props.name.toUpperCase(),
|
||||
}),
|
||||
loader: () => props => {
|
||||
// @ts-expect-error as uppercase is types as a string
|
||||
const test: number = props.uppercase;
|
||||
|
||||
return <div data-testid="test">{props.uppercase}</div>;
|
||||
},
|
||||
});
|
||||
|
||||
render(<Component name="test" />);
|
||||
|
||||
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
|
||||
'TEST',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async', () => {
|
||||
it('should create a component from a ref for async component', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
loader: async () => (props: { name: string }) => {
|
||||
return <div data-testid="test">{props.name}</div>;
|
||||
},
|
||||
});
|
||||
|
||||
render(<Component name="test" />);
|
||||
|
||||
await expect(screen.findByTestId('test')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a fallback when theres no default implementation provided', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
});
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await expect(screen.findByTestId('random')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map props from external to internal', async () => {
|
||||
const Component = createSwappableComponent({
|
||||
id: 'random',
|
||||
transformProps: (props: { name: string }) => ({
|
||||
uppercase: props.name.toUpperCase(),
|
||||
}),
|
||||
loader: 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>;
|
||||
},
|
||||
});
|
||||
|
||||
render(<Component name="test" />);
|
||||
|
||||
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
|
||||
'TEST',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 { OpaqueSwappableComponentRef } from '@internal/frontend';
|
||||
import { swappableComponentsApiRef, useApi } from '../apis';
|
||||
import { lazy } from 'react';
|
||||
|
||||
/** @public */
|
||||
export type SwappableComponentRef<
|
||||
TInnerComponentProps extends {} = {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
> = {
|
||||
id: string;
|
||||
TProps: TInnerComponentProps;
|
||||
TExternalProps: TExternalComponentProps;
|
||||
$$type: '@backstage/SwappableComponentRef';
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for creating an SwappableComponent.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CreateSwappableComponentOptions<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
> = {
|
||||
id: string;
|
||||
loader?:
|
||||
| (() => (props: TInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
|
||||
transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
|
||||
};
|
||||
|
||||
const useComponentRefApi = () => {
|
||||
try {
|
||||
return useApi(swappableComponentsApiRef);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a SwappableComponent that can be used to render the component, optionally overridden by the app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createSwappableComponent<
|
||||
TInnerComponentProps extends {},
|
||||
TExternalComponentProps extends {} = TInnerComponentProps,
|
||||
>(
|
||||
options: CreateSwappableComponentOptions<
|
||||
TInnerComponentProps,
|
||||
TExternalComponentProps
|
||||
>,
|
||||
): ((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,
|
||||
TExternalProps: null as unknown as TExternalComponentProps,
|
||||
toString() {
|
||||
return `SwappableComponentRef{id=${options.id}}`;
|
||||
},
|
||||
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 ComponentRefImpl = (props: TExternalComponentProps) => {
|
||||
const api = useComponentRefApi();
|
||||
|
||||
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: TExternalComponentProps) => JSX.Element | null);
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export {
|
||||
ExtensionBoundary,
|
||||
type ExtensionBoundaryProps,
|
||||
} from './ExtensionBoundary';
|
||||
export { coreComponentRefs } from './coreComponentRefs';
|
||||
export { createComponentRef, type ComponentRef } from './createComponentRef';
|
||||
export {
|
||||
createSwappableComponent,
|
||||
type CreateSwappableComponentOptions,
|
||||
type SwappableComponentRef,
|
||||
} from './createSwappableComponent';
|
||||
export { useAppNode } from './AppNodeProvider';
|
||||
export * from './DefaultSwappableComponents';
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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 { lazy, ComponentType } from 'react';
|
||||
import { createExtension, createExtensionDataRef } from '../wiring';
|
||||
import { ComponentRef } from '../components';
|
||||
|
||||
/** @public */
|
||||
export function createComponentExtension<TProps extends {}>(options: {
|
||||
ref: ComponentRef<TProps>;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
loader:
|
||||
| {
|
||||
lazy: () => Promise<ComponentType<TProps>>;
|
||||
}
|
||||
| {
|
||||
sync: () => ComponentType<TProps>;
|
||||
};
|
||||
}) {
|
||||
return createExtension({
|
||||
kind: 'component',
|
||||
name: options.name ?? options.ref.id,
|
||||
attachTo: { id: 'api:app/components', input: 'components' },
|
||||
disabled: options.disabled,
|
||||
output: [createComponentExtension.componentDataRef],
|
||||
factory() {
|
||||
if ('sync' in options.loader) {
|
||||
return [
|
||||
createComponentExtension.componentDataRef({
|
||||
ref: options.ref,
|
||||
impl: options.loader.sync() as ComponentType,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const lazyLoader = options.loader.lazy;
|
||||
const ExtensionComponent = lazy(() =>
|
||||
lazyLoader().then(Component => ({
|
||||
default: Component,
|
||||
})),
|
||||
) as unknown as ComponentType;
|
||||
|
||||
return [
|
||||
createComponentExtension.componentDataRef({
|
||||
ref: options.ref,
|
||||
impl: ExtensionComponent,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export namespace createComponentExtension {
|
||||
export const componentDataRef = createExtensionDataRef<{
|
||||
ref: ComponentRef;
|
||||
impl: ComponentType;
|
||||
}>().with({ id: 'core.component.component' });
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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 { createComponentExtension } from './createComponentExtension';
|
||||
@@ -24,7 +24,6 @@ export * from './analytics';
|
||||
export * from './apis';
|
||||
export * from './blueprints';
|
||||
export * from './components';
|
||||
export * from './extensions';
|
||||
export * from './icons';
|
||||
export * from './routing';
|
||||
export * from './schema';
|
||||
@@ -33,7 +32,7 @@ export * from './translation';
|
||||
export * from './wiring';
|
||||
|
||||
export type {
|
||||
CoreProgressProps,
|
||||
CoreNotFoundErrorPageProps,
|
||||
CoreErrorBoundaryFallbackProps,
|
||||
ProgressProps,
|
||||
NotFoundErrorPageProps,
|
||||
ErrorDisplayProps,
|
||||
} from './types';
|
||||
|
||||
@@ -18,15 +18,15 @@ import { ReactNode } from 'react';
|
||||
import { FrontendPlugin } from './wiring';
|
||||
|
||||
/** @public */
|
||||
export type CoreProgressProps = {};
|
||||
export type ProgressProps = {};
|
||||
|
||||
/** @public */
|
||||
export type CoreNotFoundErrorPageProps = {
|
||||
export type NotFoundErrorPageProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type CoreErrorBoundaryFallbackProps = {
|
||||
export type ErrorDisplayProps = {
|
||||
plugin?: FrontendPlugin;
|
||||
error: Error;
|
||||
resetError: () => void;
|
||||
|
||||
@@ -214,7 +214,6 @@ type AnyParamsInput<TParams extends object | ExtensionBlueprintDefineParams> =
|
||||
* @public
|
||||
*/
|
||||
export interface ExtensionBlueprint<
|
||||
// TParamsMapper extends (params: any) => object,
|
||||
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
|
||||
> {
|
||||
dataRefs: T['dataRefs'];
|
||||
|
||||
+201
-31
@@ -8,7 +8,6 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
|
||||
import { ApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { AppTheme } from '@backstage/frontend-plugin-api';
|
||||
import { ComponentRef } from '@backstage/frontend-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
|
||||
@@ -23,6 +22,7 @@ import { NavContentComponent } from '@backstage/frontend-plugin-api';
|
||||
import { ReactNode } from 'react';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { SignInPageProps } from '@backstage/core-plugin-api';
|
||||
import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
|
||||
import { TranslationMessages } from '@backstage/frontend-plugin-api';
|
||||
import { TranslationResource } from '@backstage/frontend-plugin-api';
|
||||
|
||||
@@ -315,36 +315,6 @@ const appPlugin: FrontendPlugin<
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/components': ExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
|
||||
inputs: {
|
||||
components: ExtensionInput<
|
||||
ConfigurableExtensionDataRef<
|
||||
{
|
||||
ref: ComponentRef;
|
||||
impl: ComponentType;
|
||||
},
|
||||
'core.component.component',
|
||||
{}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
optional: false;
|
||||
}
|
||||
>;
|
||||
};
|
||||
kind: 'api';
|
||||
name: 'components';
|
||||
params: <
|
||||
TApi,
|
||||
TImpl extends TApi,
|
||||
TDeps extends { [name in string]: unknown },
|
||||
>(
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/dialog': ExtensionDefinition<{
|
||||
kind: 'api';
|
||||
name: 'dialog';
|
||||
@@ -614,6 +584,38 @@ const appPlugin: FrontendPlugin<
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/swappable-components': ExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
|
||||
inputs: {
|
||||
components: ExtensionInput<
|
||||
ConfigurableExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
optional: false;
|
||||
}
|
||||
>;
|
||||
};
|
||||
kind: 'api';
|
||||
name: 'swappable-components';
|
||||
params: <
|
||||
TApi,
|
||||
TImpl extends TApi,
|
||||
TDeps extends { [name in string]: unknown },
|
||||
>(
|
||||
params: ApiFactory<TApi, TImpl, TDeps>,
|
||||
) => ExtensionBlueprintParams<AnyApiFactory>;
|
||||
}>;
|
||||
'api:app/translations': ExtensionDefinition<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
@@ -726,6 +728,174 @@ const appPlugin: FrontendPlugin<
|
||||
element: JSX.Element;
|
||||
};
|
||||
}>;
|
||||
'component:app/core.components.errorBoundary': ExtensionDefinition<{
|
||||
kind: 'component';
|
||||
name: 'core.components.errorBoundary';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: <Ref extends SwappableComponentRef<any>>(params: {
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}>;
|
||||
}>;
|
||||
'component:app/core.components.notFoundErrorPage': ExtensionDefinition<{
|
||||
kind: 'component';
|
||||
name: 'core.components.notFoundErrorPage';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: <Ref extends SwappableComponentRef<any>>(params: {
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}>;
|
||||
}>;
|
||||
'component:app/core.components.progress': ExtensionDefinition<{
|
||||
kind: 'component';
|
||||
name: 'core.components.progress';
|
||||
config: {};
|
||||
configInput: {};
|
||||
output: ExtensionDataRef<
|
||||
{
|
||||
ref: SwappableComponentRef;
|
||||
loader:
|
||||
| (() => (props: {}) => JSX.Element | null)
|
||||
| (() => Promise<(props: {}) => JSX.Element | null>);
|
||||
},
|
||||
'core.swappableComponent',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
params: <Ref extends SwappableComponentRef<any>>(params: {
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
component: Ref extends SwappableComponentRef<
|
||||
any,
|
||||
infer IExternalComponentProps
|
||||
>
|
||||
? {
|
||||
ref: Ref;
|
||||
} & ((props: IExternalComponentProps) => JSX.Element | null)
|
||||
: never;
|
||||
loader: Ref extends SwappableComponentRef<
|
||||
infer IInnerComponentProps,
|
||||
any
|
||||
>
|
||||
?
|
||||
| (() => (props: IInnerComponentProps) => JSX.Element | null)
|
||||
| (() => Promise<
|
||||
(props: IInnerComponentProps) => JSX.Element | null
|
||||
>)
|
||||
: never;
|
||||
}>;
|
||||
}>;
|
||||
'sign-in-page:app': ExtensionDefinition<{
|
||||
kind: 'sign-in-page';
|
||||
name: undefined;
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
createExtension,
|
||||
coreExtensionData,
|
||||
createExtensionInput,
|
||||
coreComponentRefs,
|
||||
useComponentRef,
|
||||
NotFoundErrorPage,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { useRoutes } from 'react-router-dom';
|
||||
|
||||
@@ -36,10 +35,6 @@ export const AppRoutes = createExtension({
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory({ inputs }) {
|
||||
const Routes = () => {
|
||||
const NotFoundErrorPage = useComponentRef(
|
||||
coreComponentRefs.notFoundErrorPage,
|
||||
);
|
||||
|
||||
const element = useRoutes([
|
||||
...inputs.routes.map(route => ({
|
||||
path: `${route
|
||||
|
||||
+11
-12
@@ -15,34 +15,33 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createComponentExtension,
|
||||
SwappableComponentBlueprint,
|
||||
createExtensionInput,
|
||||
ApiBlueprint,
|
||||
componentsApiRef,
|
||||
swappableComponentsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { DefaultComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/ComponentsApi';
|
||||
import { DefaultSwappableComponentsApi } from '../../../../packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi';
|
||||
|
||||
/**
|
||||
* Contains the shareable components installed into the app.
|
||||
*/
|
||||
export const ComponentsApi = ApiBlueprint.makeWithOverrides({
|
||||
name: 'components',
|
||||
export const SwappableComponentsApi = ApiBlueprint.makeWithOverrides({
|
||||
name: 'swappable-components',
|
||||
inputs: {
|
||||
components: createExtensionInput(
|
||||
[createComponentExtension.componentDataRef],
|
||||
{ replaces: [{ id: 'app', input: 'components' }] },
|
||||
),
|
||||
components: createExtensionInput([
|
||||
SwappableComponentBlueprint.dataRefs.component,
|
||||
]),
|
||||
},
|
||||
factory: (originalFactory, { inputs }) => {
|
||||
return originalFactory(defineParams =>
|
||||
defineParams({
|
||||
api: componentsApiRef,
|
||||
api: swappableComponentsApiRef,
|
||||
deps: {},
|
||||
factory: () =>
|
||||
DefaultComponentsApi.fromComponents(
|
||||
DefaultSwappableComponentsApi.fromComponents(
|
||||
inputs.components.map(i =>
|
||||
i.get(createComponentExtension.componentDataRef),
|
||||
i.get(SwappableComponentBlueprint.dataRefs.component),
|
||||
),
|
||||
),
|
||||
}),
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
* 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.
|
||||
@@ -13,41 +13,54 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Button from '@material-ui/core/Button';
|
||||
import {
|
||||
NotFoundErrorPage as SwappableNotFoundErrorPage,
|
||||
Progress as SwappableProgress,
|
||||
ErrorDisplay as SwappableErrorDisplay,
|
||||
SwappableComponentBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
import {
|
||||
createComponentExtension,
|
||||
coreComponentRefs,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ErrorPanel } from '@backstage/core-components';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { components as defaultComponents } from '../../../../packages/app-defaults/src/defaults';
|
||||
ErrorPage,
|
||||
ErrorPanel,
|
||||
Progress as ProgressComponent,
|
||||
} from '@backstage/core-components';
|
||||
import Button from '@material-ui/core/Button';
|
||||
|
||||
export const DefaultProgressComponent = createComponentExtension({
|
||||
ref: coreComponentRefs.progress,
|
||||
loader: { sync: () => defaultComponents.Progress },
|
||||
export const Progress = SwappableComponentBlueprint.make({
|
||||
name: 'core.components.progress',
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableProgress,
|
||||
loader: () => ProgressComponent,
|
||||
}),
|
||||
});
|
||||
|
||||
export const DefaultNotFoundErrorPageComponent = createComponentExtension({
|
||||
ref: coreComponentRefs.notFoundErrorPage,
|
||||
loader: { sync: () => defaultComponents.NotFoundErrorPage },
|
||||
export const NotFoundErrorPage = SwappableComponentBlueprint.make({
|
||||
name: 'core.components.notFoundErrorPage',
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableNotFoundErrorPage,
|
||||
loader: () => () =>
|
||||
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />,
|
||||
}),
|
||||
});
|
||||
|
||||
export const DefaultErrorBoundaryComponent = createComponentExtension({
|
||||
ref: coreComponentRefs.errorBoundaryFallback,
|
||||
loader: {
|
||||
sync: () => props => {
|
||||
const { plugin, error, resetError } = props;
|
||||
const title = `Error in ${plugin?.id}`;
|
||||
|
||||
return (
|
||||
<ErrorPanel title={title} error={error} defaultExpanded>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
},
|
||||
},
|
||||
export const ErrorBoundary = SwappableComponentBlueprint.make({
|
||||
name: 'core.components.errorBoundary',
|
||||
params: define =>
|
||||
define({
|
||||
component: SwappableErrorDisplay,
|
||||
loader: () => props => {
|
||||
const { plugin, error, resetError } = props;
|
||||
const title = `Error in ${plugin?.id}`;
|
||||
return (
|
||||
<ErrorPanel title={title} error={error} defaultExpanded>
|
||||
<Button variant="outlined" onClick={resetError}>
|
||||
Retry
|
||||
</Button>
|
||||
</ErrorPanel>
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -20,18 +20,14 @@ export { AppNav } from './AppNav';
|
||||
export { AppRoot } from './AppRoot';
|
||||
export { AppRoutes } from './AppRoutes';
|
||||
export { AppThemeApi, DarkTheme, LightTheme } from './AppThemeApi';
|
||||
export { ComponentsApi } from './ComponentsApi';
|
||||
export { SwappableComponentsApi } from './SwappableComponentsApi';
|
||||
export { IconsApi } from './IconsApi';
|
||||
export { FeatureFlagsApi } from './FeatureFlagsApi';
|
||||
export { TranslationsApi } from './TranslationsApi';
|
||||
export { DefaultSignInPage } from './DefaultSignInPage';
|
||||
export { dialogDisplayAppRootElement } from './DialogDisplay';
|
||||
export {
|
||||
DefaultProgressComponent,
|
||||
DefaultErrorBoundaryComponent,
|
||||
DefaultNotFoundErrorPageComponent,
|
||||
} from './components';
|
||||
export {
|
||||
oauthRequestDialogAppRootElement,
|
||||
alertDisplayAppRootElement,
|
||||
} from './elements';
|
||||
export { Progress, NotFoundErrorPage, ErrorBoundary } from './components';
|
||||
|
||||
@@ -25,17 +25,17 @@ import {
|
||||
AppThemeApi,
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
ComponentsApi,
|
||||
SwappableComponentsApi,
|
||||
IconsApi,
|
||||
FeatureFlagsApi,
|
||||
TranslationsApi,
|
||||
DefaultProgressComponent,
|
||||
DefaultNotFoundErrorPageComponent,
|
||||
DefaultErrorBoundaryComponent,
|
||||
oauthRequestDialogAppRootElement,
|
||||
alertDisplayAppRootElement,
|
||||
DefaultSignInPage,
|
||||
dialogDisplayAppRootElement,
|
||||
Progress,
|
||||
NotFoundErrorPage,
|
||||
ErrorBoundary,
|
||||
} from './extensions';
|
||||
import { apis } from './defaultApis';
|
||||
|
||||
@@ -54,16 +54,16 @@ export const appPlugin = createFrontendPlugin({
|
||||
AppThemeApi,
|
||||
DarkTheme,
|
||||
LightTheme,
|
||||
ComponentsApi,
|
||||
SwappableComponentsApi,
|
||||
IconsApi,
|
||||
FeatureFlagsApi,
|
||||
TranslationsApi,
|
||||
DefaultProgressComponent,
|
||||
DefaultNotFoundErrorPageComponent,
|
||||
DefaultErrorBoundaryComponent,
|
||||
DefaultSignInPage,
|
||||
oauthRequestDialogAppRootElement,
|
||||
alertDisplayAppRootElement,
|
||||
dialogDisplayAppRootElement,
|
||||
Progress,
|
||||
NotFoundErrorPage,
|
||||
ErrorBoundary,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4367,6 +4367,7 @@ __metadata:
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/frontend-defaults": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-app": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
@@ -32293,12 +32294,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"for-each@npm:^0.3.3":
|
||||
version: 0.3.3
|
||||
resolution: "for-each@npm:0.3.3"
|
||||
"for-each@npm:^0.3.3, for-each@npm:^0.3.5":
|
||||
version: 0.3.5
|
||||
resolution: "for-each@npm:0.3.5"
|
||||
dependencies:
|
||||
is-callable: "npm:^1.1.3"
|
||||
checksum: 10/fdac0cde1be35610bd635ae958422e8ce0cc1313e8d32ea6d34cfda7b60850940c1fd07c36456ad76bd9c24aef6ff5e03b02beb58c83af5ef6c968a64eada676
|
||||
is-callable: "npm:^1.2.7"
|
||||
checksum: 10/330cc2439f85c94f4609de3ee1d32c5693ae15cdd7fe3d112c4fd9efd4ce7143f2c64ef6c2c9e0cfdb0058437f33ef05b5bdae5b98fcc903fb2143fbaf0fea0f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -34905,7 +34906,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-callable@npm:^1.1.3, is-callable@npm:^1.1.5, is-callable@npm:^1.2.7":
|
||||
"is-callable@npm:^1.1.5, is-callable@npm:^1.2.7":
|
||||
version: 1.2.7
|
||||
resolution: "is-callable@npm:1.2.7"
|
||||
checksum: 10/48a9297fb92c99e9df48706241a189da362bff3003354aea4048bd5f7b2eb0d823cd16d0a383cece3d76166ba16d85d9659165ac6fcce1ac12e6c649d66dbdb9
|
||||
@@ -45106,14 +45107,16 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"regexp.prototype.flags@npm:^1.5.3":
|
||||
version: 1.5.3
|
||||
resolution: "regexp.prototype.flags@npm:1.5.3"
|
||||
version: 1.5.4
|
||||
resolution: "regexp.prototype.flags@npm:1.5.4"
|
||||
dependencies:
|
||||
call-bind: "npm:^1.0.7"
|
||||
call-bind: "npm:^1.0.8"
|
||||
define-properties: "npm:^1.2.1"
|
||||
es-errors: "npm:^1.3.0"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
set-function-name: "npm:^2.0.2"
|
||||
checksum: 10/fe17bc4eebbc72945aaf9dd059eb7784a5ca453a67cc4b5b3e399ab08452c9a05befd92063e2c52e7b24d9238c60031656af32dd57c555d1ba6330dbf8c23b43
|
||||
checksum: 10/8ab897ca445968e0b96f6237641510f3243e59c180ee2ee8d83889c52ff735dd1bf3657fcd36db053e35e1d823dd53f2565d0b8021ea282c9fe62401c6c3bd6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -50787,16 +50790,17 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2":
|
||||
version: 1.1.18
|
||||
resolution: "which-typed-array@npm:1.1.18"
|
||||
version: 1.1.19
|
||||
resolution: "which-typed-array@npm:1.1.19"
|
||||
dependencies:
|
||||
available-typed-arrays: "npm:^1.0.7"
|
||||
call-bind: "npm:^1.0.8"
|
||||
call-bound: "npm:^1.0.3"
|
||||
for-each: "npm:^0.3.3"
|
||||
call-bound: "npm:^1.0.4"
|
||||
for-each: "npm:^0.3.5"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
has-tostringtag: "npm:^1.0.2"
|
||||
checksum: 10/11eed801b2bd08cdbaecb17aff381e0fb03526532f61acc06e6c7b9370e08062c33763a51f27825f13fdf34aabd0df6104007f4e8f96e6eaef7db0ce17a26d6e
|
||||
checksum: 10/12be30fb88567f9863186bee1777f11bea09dd59ed8b3ce4afa7dd5cade75e2f4cc56191a2da165113cc7cf79987ba021dac1e22b5b62aa7e5c56949f2469a68
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user