frontend-app-api: extract createApp out into frontend-defaults
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -4,33 +4,20 @@
|
||||
|
||||
```ts
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { createApp as createApp_2 } from '@backstage/frontend-defaults';
|
||||
import { CreateAppFeatureLoader as CreateAppFeatureLoader_2 } from '@backstage/frontend-defaults';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendModule } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { SubRouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export function createApp(options?: {
|
||||
features?: (FrontendFeature | CreateAppFeatureLoader)[];
|
||||
configLoader?: () => Promise<{
|
||||
config: ConfigApi;
|
||||
}>;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
loadingComponent?: ReactNode;
|
||||
}): {
|
||||
createRoot(): JSX_2.Element;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export const createApp: typeof createApp_2;
|
||||
|
||||
// @public
|
||||
export interface CreateAppFeatureLoader {
|
||||
getLoaderName(): string;
|
||||
load(options: { config: ConfigApi }): Promise<{
|
||||
features: FrontendFeature[];
|
||||
}>;
|
||||
}
|
||||
// @public @deprecated (undocumented)
|
||||
export type CreateAppFeatureLoader = CreateAppFeatureLoader_2;
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
|
||||
@@ -34,21 +34,18 @@
|
||||
"dependencies": {
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/frontend-defaults": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.0"
|
||||
|
||||
@@ -1,436 +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 {
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
createExtensionOverrides,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
ThemeBlueprint,
|
||||
createFrontendModule,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { CreateAppFeatureLoader, createApp } from './createApp';
|
||||
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
|
||||
describe('createApp', () => {
|
||||
it('should allow themes to be installed', async () => {
|
||||
const app = createApp({
|
||||
configLoader: async () => ({
|
||||
config: new MockConfigApi({
|
||||
app: {
|
||||
extensions: [
|
||||
{ 'theme:app/light': false },
|
||||
{ 'theme:app/dark': false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
ThemeBlueprint.make({
|
||||
name: 'derp',
|
||||
params: {
|
||||
theme: {
|
||||
id: 'derp',
|
||||
title: 'Derp',
|
||||
variant: 'dark',
|
||||
Provider: () => <div>Derp</div>,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
configLoader: async () => ({ config: new MockConfigApi({}) }),
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: duplicatedFeatureId,
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: {
|
||||
defaultPath: '/',
|
||||
loader: async () => <div>First Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
id: duplicatedFeatureId,
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: {
|
||||
defaultPath: '/',
|
||||
loader: async () => <div>Last Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('First Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Last Page')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should support feature loaders', async () => {
|
||||
const loader: CreateAppFeatureLoader = {
|
||||
getLoaderName() {
|
||||
return 'test-loader';
|
||||
},
|
||||
async load({ config }) {
|
||||
return {
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: {
|
||||
defaultPath: '/',
|
||||
loader: async () => <div>{config.getString('key')}</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const app = createApp({
|
||||
configLoader: async () => ({
|
||||
config: new MockConfigApi({ key: 'config-value' }),
|
||||
}),
|
||||
features: [appPlugin, loader],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(
|
||||
screen.findByText('config-value'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should propagate errors thrown by feature loaders', async () => {
|
||||
const loader: CreateAppFeatureLoader = {
|
||||
getLoaderName() {
|
||||
return 'test-loader';
|
||||
},
|
||||
async load() {
|
||||
throw new TypeError('boom');
|
||||
},
|
||||
};
|
||||
|
||||
const app = createApp({
|
||||
configLoader: async () => ({
|
||||
config: new MockConfigApi({}),
|
||||
}),
|
||||
features: [loader],
|
||||
});
|
||||
|
||||
await expect(
|
||||
renderWithEffects(app.createRoot()),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failed to read frontend features from loader 'test-loader', TypeError: boom"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should register feature flags', async () => {
|
||||
const app = createApp({
|
||||
configLoader: async () => ({ config: new MockConfigApi({}) }),
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
featureFlags: [{ name: 'test-1' }],
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'first',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory() {
|
||||
const Component = () => {
|
||||
const flagsApi = useApi(featureFlagsApiRef);
|
||||
return (
|
||||
<div>
|
||||
Flags:{' '}
|
||||
{flagsApi
|
||||
.getRegisteredFlags()
|
||||
.map(flag => `${flag.name} from '${flag.pluginId}'`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return [coreExtensionData.reactElement(<Component />)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createExtensionOverrides({
|
||||
featureFlags: [{ name: 'test-2' }],
|
||||
extensions: [
|
||||
createExtension({
|
||||
namespace: 'app',
|
||||
name: 'root',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
disabled: true,
|
||||
output: [],
|
||||
factory: () => [],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(
|
||||
screen.findByText("Flags: test-1 from 'test', test-2 from ''"),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
const app = createApp({
|
||||
configLoader: async () => ({ config: new MockConfigApi({}) }),
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'my-plugin',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: {
|
||||
defaultPath: '/',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
appTreeApi = useApi(appTreeApiRef);
|
||||
return <div>My Plugin Page</div>;
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(appTreeApi).toBeDefined();
|
||||
const { tree } = appTreeApi!.getTree();
|
||||
|
||||
expect(String(tree.root)).toMatchInlineSnapshot(`
|
||||
"<root out=[core.reactElement]>
|
||||
apis [
|
||||
<api:app/discovery out=[core.api.factory] />
|
||||
<api:app/alert out=[core.api.factory] />
|
||||
<api:app/analytics out=[core.api.factory] />
|
||||
<api:app/error out=[core.api.factory] />
|
||||
<api:app/storage out=[core.api.factory] />
|
||||
<api:app/fetch out=[core.api.factory] />
|
||||
<api:app/oauth-request out=[core.api.factory] />
|
||||
<api:app/google-auth out=[core.api.factory] />
|
||||
<api:app/microsoft-auth out=[core.api.factory] />
|
||||
<api:app/github-auth out=[core.api.factory] />
|
||||
<api:app/okta-auth out=[core.api.factory] />
|
||||
<api:app/gitlab-auth out=[core.api.factory] />
|
||||
<api:app/onelogin-auth out=[core.api.factory] />
|
||||
<api:app/bitbucket-auth out=[core.api.factory] />
|
||||
<api:app/bitbucket-server-auth out=[core.api.factory] />
|
||||
<api:app/atlassian-auth out=[core.api.factory] />
|
||||
<api:app/vmware-cloud-auth out=[core.api.factory] />
|
||||
<api:app/permission out=[core.api.factory] />
|
||||
<api:app/app-language out=[core.api.factory] />
|
||||
<api:app/app-theme out=[core.api.factory]>
|
||||
themes [
|
||||
<theme:app/dark out=[core.theme.theme] />
|
||||
<theme:app/light out=[core.theme.theme] />
|
||||
]
|
||||
</api:app/app-theme>
|
||||
<api:app/components out=[core.api.factory]>
|
||||
components [
|
||||
<component:core.components.progress out=[core.component.component] />
|
||||
<component:core.components.notFoundErrorPage out=[core.component.component] />
|
||||
<component:core.components.errorBoundaryFallback out=[core.component.component] />
|
||||
]
|
||||
</api:app/components>
|
||||
<api:app/icons out=[core.api.factory] />
|
||||
<api:app/feature-flags out=[core.api.factory] />
|
||||
<api:app/translations out=[core.api.factory] />
|
||||
]
|
||||
app [
|
||||
<app out=[core.reactElement]>
|
||||
root [
|
||||
<app/root out=[core.reactElement]>
|
||||
children [
|
||||
<app/layout out=[core.reactElement]>
|
||||
nav [
|
||||
<app/nav out=[core.reactElement] />
|
||||
]
|
||||
content [
|
||||
<app/routes out=[core.reactElement]>
|
||||
routes [
|
||||
<page:my-plugin out=[core.routing.path, core.reactElement] />
|
||||
]
|
||||
</app/routes>
|
||||
]
|
||||
</app/layout>
|
||||
]
|
||||
elements [
|
||||
<app-root-element:app/oauth-request-dialog out=[core.reactElement] />
|
||||
<app-root-element:app/alert-display out=[core.reactElement] />
|
||||
]
|
||||
</app/root>
|
||||
]
|
||||
</app>
|
||||
]
|
||||
</root>"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should use "Loading..." as the default suspense fallback', async () => {
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Loading...')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use no suspense fallback if the "loadingComponent" is null', async () => {
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
loadingComponent: null,
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(screen.queryByText('Loading...')).toBeNull();
|
||||
});
|
||||
|
||||
it('should use a custom "loadingComponent"', async () => {
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
loadingComponent: <span>"Custom loading message"</span>,
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(screen.queryByText('Custom loading message')).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow overriding the app plugin', async () => {
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
features: [
|
||||
appPlugin.withOverrides({
|
||||
extensions: [
|
||||
appPlugin.getExtension('app/root').override({
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(
|
||||
<div>Custom app root element</div>,
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(screen.queryByText('Custom app root element')).toBeNull();
|
||||
});
|
||||
|
||||
describe('modules', () => {
|
||||
it('should be able to override extensions with a plugin extension override', async () => {
|
||||
const mod = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPlugin.getExtension('app/root').override({
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(
|
||||
<div>Custom app root element</div>,
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
features: [mod],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(screen.queryByText('Custom app root element')).toBeNull();
|
||||
});
|
||||
|
||||
it('should be able to override extensions with a standalone extension override', async () => {
|
||||
const mod = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'root',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(
|
||||
<div>Custom app root element</div>,
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
configLoader: () => new Promise(() => {}),
|
||||
features: [mod],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
expect(screen.queryByText('Custom app root element')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/*
|
||||
* 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 {
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createExtension,
|
||||
createFrontendPlugin,
|
||||
ApiBlueprint,
|
||||
createRouteRef,
|
||||
createExternalRouteRef,
|
||||
createExtensionInput,
|
||||
useRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { screen, render } from '@testing-library/react';
|
||||
import { createSpecializedApp } from './createSpecializedApp';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
|
||||
describe('createSpecializedApp', () => {
|
||||
it('should render the root app', () => {
|
||||
const app = createSpecializedApp({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<div>Test</div>)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(app.createRoot());
|
||||
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', () => {
|
||||
const app = createSpecializedApp({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(<div>Test 1</div>),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [
|
||||
coreExtensionData.reactElement(<div>Test 2</div>),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(app.createRoot());
|
||||
|
||||
expect(screen.getByText('Test 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should forward config', () => {
|
||||
const app = createSpecializedApp({
|
||||
config: new MockConfigApi({ test: 'foo' }),
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => [
|
||||
coreExtensionData.reactElement(
|
||||
<div>Test {apis.get(configApiRef)!.getString('test')}</div>,
|
||||
),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(app.createRoot());
|
||||
|
||||
expect(screen.getByText('Test foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should support APIs and feature flags', async () => {
|
||||
const flags = new Array<{ name: string; pluginId: string }>();
|
||||
const app = createSpecializedApp({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
featureFlags: [{ name: 'a' }, { name: 'b' }],
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => [
|
||||
coreExtensionData.reactElement(
|
||||
<div>
|
||||
flags:
|
||||
{apis
|
||||
.get(featureFlagsApiRef)!
|
||||
.getRegisteredFlags()
|
||||
.map(f => `${f.pluginId}=${f.name}`)
|
||||
.join(',')}
|
||||
</div>,
|
||||
),
|
||||
],
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
params: {
|
||||
factory: createApiFactory(featureFlagsApiRef, {
|
||||
registerFlag(flag) {
|
||||
flags.push(flag);
|
||||
},
|
||||
getRegisteredFlags() {
|
||||
return flags;
|
||||
},
|
||||
} as typeof featureFlagsApiRef.T),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(app.createRoot());
|
||||
|
||||
expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
createSpecializedApp({
|
||||
features: [
|
||||
createFrontendPlugin({
|
||||
id: 'test',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis }) => {
|
||||
appTreeApi = apis.get(appTreeApiRef);
|
||||
return [coreExtensionData.reactElement(<div />)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(`
|
||||
"<root out=[core.reactElement]>
|
||||
app [
|
||||
<test out=[core.reactElement] />
|
||||
]
|
||||
</root>"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should support route bindings', async () => {
|
||||
const routeRef = createRouteRef();
|
||||
const extRouteRef = createExternalRouteRef();
|
||||
|
||||
const pluginA = createFrontendPlugin({
|
||||
id: 'a',
|
||||
externalRoutes: {
|
||||
ext: extRouteRef,
|
||||
},
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'parent',
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
inputs: {
|
||||
children: createExtensionInput([coreExtensionData.reactElement]),
|
||||
},
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: ({ apis, inputs }) => {
|
||||
return [
|
||||
coreExtensionData.reactElement(
|
||||
<ApiProvider apis={apis}>
|
||||
<MemoryRouter>
|
||||
{inputs.children.map(i => (
|
||||
<React.Fragment key={i.node.spec.id}>
|
||||
{i.get(coreExtensionData.reactElement)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MemoryRouter>
|
||||
</ApiProvider>,
|
||||
),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createExtension({
|
||||
name: 'child',
|
||||
attachTo: { id: 'a/parent', input: 'children' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => {
|
||||
const Component = () => {
|
||||
const link = useRouteRef(extRouteRef);
|
||||
return <div>link: {link?.() ?? 'none'}</div>;
|
||||
};
|
||||
return [coreExtensionData.reactElement(<Component />)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const pluginB = createFrontendPlugin({
|
||||
id: 'b',
|
||||
routes: {
|
||||
root: routeRef,
|
||||
},
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'child',
|
||||
attachTo: { id: 'a/parent', input: 'children' },
|
||||
output: [
|
||||
coreExtensionData.reactElement,
|
||||
coreExtensionData.routePath,
|
||||
coreExtensionData.routeRef,
|
||||
],
|
||||
factory: () => {
|
||||
return [
|
||||
coreExtensionData.reactElement(<div />),
|
||||
coreExtensionData.routePath('/test'),
|
||||
coreExtensionData.routeRef(routeRef),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
createSpecializedApp({
|
||||
features: [pluginA, pluginB],
|
||||
bindRoutes({ bind }) {
|
||||
bind(pluginA.externalRoutes, { ext: pluginB.routes.root });
|
||||
},
|
||||
}).createRoot(),
|
||||
);
|
||||
|
||||
expect(screen.getByText('link: /test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+28
-110
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { JSX, ReactNode } from 'react';
|
||||
import React, { JSX } from 'react';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
ApiBlueprint,
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createApiFactory,
|
||||
routeResolutionApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
@@ -41,15 +40,14 @@ import {
|
||||
featureFlagsApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { getAvailableFeatures } from './discovery';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
|
||||
|
||||
// TODO: Get rid of all of these
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultConfigLoader';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs';
|
||||
import {
|
||||
createApp as _createApp,
|
||||
CreateAppFeatureLoader as _CreateAppFeatureLoader,
|
||||
} from '@backstage/frontend-defaults';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
@@ -71,7 +69,6 @@ import {
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
|
||||
import { stringifyError } from '@backstage/errors';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
@@ -83,7 +80,6 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { BackstageRouteObject } from '../routing/types';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
import { FrontendFeature } from './types';
|
||||
|
||||
function deduplicateFeatures(
|
||||
@@ -109,93 +105,6 @@ function deduplicateFeatures(
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of dynamically loaded frontend features.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CreateAppFeatureLoader {
|
||||
/**
|
||||
* Returns name of this loader. suitable for showing to users.
|
||||
*/
|
||||
getLoaderName(): string;
|
||||
|
||||
/**
|
||||
* Loads a number of features dynamically.
|
||||
*/
|
||||
load(options: { config: ConfigApi }): Promise<{
|
||||
features: FrontendFeature[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createApp(options?: {
|
||||
features?: (FrontendFeature | CreateAppFeatureLoader)[];
|
||||
configLoader?: () => Promise<{ config: ConfigApi }>;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
/**
|
||||
* The component to render while loading the app (waiting for config, features, etc)
|
||||
*
|
||||
* Is the text "Loading..." by default.
|
||||
* If set to "null" then no loading fallback component is rendered. *
|
||||
*/
|
||||
loadingComponent?: ReactNode;
|
||||
}): {
|
||||
createRoot(): JSX.Element;
|
||||
} {
|
||||
let suspenseFallback = options?.loadingComponent;
|
||||
if (suspenseFallback === undefined) {
|
||||
suspenseFallback = 'Loading...';
|
||||
}
|
||||
|
||||
async function appLoader() {
|
||||
const config =
|
||||
(await options?.configLoader?.().then(c => c.config)) ??
|
||||
ConfigReader.fromConfigs(
|
||||
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
|
||||
);
|
||||
|
||||
const discoveredFeatures = getAvailableFeatures(config);
|
||||
|
||||
const providedFeatures: FrontendFeature[] = [];
|
||||
for (const entry of options?.features ?? []) {
|
||||
if ('load' in entry) {
|
||||
try {
|
||||
const result = await entry.load({ config });
|
||||
providedFeatures.push(...result.features);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
providedFeatures.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const app = createSpecializedApp({
|
||||
config,
|
||||
features: [...discoveredFeatures, ...providedFeatures],
|
||||
bindRoutes: options?.bindRoutes,
|
||||
}).createRoot();
|
||||
|
||||
return { default: () => app };
|
||||
}
|
||||
|
||||
return {
|
||||
createRoot() {
|
||||
const LazyApp = React.lazy(appLoader);
|
||||
return (
|
||||
<React.Suspense fallback={suspenseFallback}>
|
||||
<LazyApp />
|
||||
</React.Suspense>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class AppTreeApiProxy implements AppTreeApi {
|
||||
#safeToUse: boolean = false;
|
||||
@@ -267,8 +176,21 @@ class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of {@link createApp}, expecting all features and
|
||||
* config to have been loaded already.
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/frontend-defaults` instead.
|
||||
*/
|
||||
export const createApp = _createApp;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/frontend-defaults` instead.
|
||||
*/
|
||||
export type CreateAppFeatureLoader = _CreateAppFeatureLoader;
|
||||
|
||||
/**
|
||||
* Creates an empty app without any default features. This is a low-level API is
|
||||
* intended for use in tests or specialized setups. Typically wou want to use
|
||||
* `createApp` from `@backstage/frontend-defaults` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
@@ -277,12 +199,8 @@ export function createSpecializedApp(options?: {
|
||||
config?: ConfigApi;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
}): { createRoot(): JSX.Element } {
|
||||
const {
|
||||
features: featuresWithoutApp = [],
|
||||
config = new ConfigReader({}, 'empty-config'),
|
||||
} = options ?? {};
|
||||
|
||||
const features = deduplicateFeatures([appPlugin, ...featuresWithoutApp]);
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []);
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
@@ -317,12 +235,6 @@ export function createSpecializedApp(options?: {
|
||||
],
|
||||
});
|
||||
|
||||
// Now instantiate the entire tree, which will skip anything that's already been instantiated
|
||||
instantiateAppNodeTree(tree.root, apiHolder);
|
||||
|
||||
routeResolutionApi.initialize();
|
||||
appTreeApi.initialize();
|
||||
|
||||
const featureFlagApi = apiHolder.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
for (const feature of features) {
|
||||
@@ -350,6 +262,12 @@ export function createSpecializedApp(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
// Now instantiate the entire tree, which will skip anything that's already been instantiated
|
||||
instantiateAppNodeTree(tree.root, apiHolder);
|
||||
|
||||
routeResolutionApi.initialize();
|
||||
appTreeApi.initialize();
|
||||
|
||||
const rootEl = tree.root.instance!.getData(coreExtensionData.reactElement);
|
||||
|
||||
const AppComponent = () => rootEl;
|
||||
@@ -1,86 +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 { createFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { getAvailableFeatures } from './discovery';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
const globalSpy = jest.fn();
|
||||
Object.defineProperty(global, '__@backstage/discovered__', {
|
||||
get: globalSpy,
|
||||
});
|
||||
|
||||
const config = new ConfigReader({
|
||||
app: { experimental: { packages: 'all' } },
|
||||
});
|
||||
|
||||
describe('getAvailableFeatures', () => {
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('should discover nothing with undefined global', () => {
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should discover nothing with empty global', () => {
|
||||
globalSpy.mockReturnValue({
|
||||
modules: [],
|
||||
});
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should discover a plugin', () => {
|
||||
const testPlugin = createFrontendPlugin({ id: 'test' });
|
||||
globalSpy.mockReturnValue({
|
||||
modules: [{ default: testPlugin }],
|
||||
});
|
||||
expect(getAvailableFeatures(config)).toEqual([testPlugin]);
|
||||
});
|
||||
|
||||
it('should ignore garbage', () => {
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: null }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: undefined }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: Symbol() }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: () => {} }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: 0 }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: false }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
globalSpy.mockReturnValueOnce({ modules: [{ default: true }] });
|
||||
expect(getAvailableFeatures(config)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should discover multiple plugins', () => {
|
||||
const test1Plugin = createFrontendPlugin({ id: 'test1' });
|
||||
const test2Plugin = createFrontendPlugin({ id: 'test2' });
|
||||
const test3Plugin = createFrontendPlugin({ id: 'test3' });
|
||||
globalSpy.mockReturnValue({
|
||||
modules: [
|
||||
{ default: test1Plugin },
|
||||
{ default: test2Plugin },
|
||||
{ default: test3Plugin },
|
||||
],
|
||||
});
|
||||
expect(getAvailableFeatures(config)).toEqual([
|
||||
test1Plugin,
|
||||
test2Plugin,
|
||||
test3Plugin,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +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 { Config, ConfigReader } from '@backstage/config';
|
||||
import { FrontendFeature } from '@backstage/frontend-app-api';
|
||||
|
||||
interface DiscoveryGlobal {
|
||||
modules: Array<{ name: string; export?: string; default: unknown }>;
|
||||
}
|
||||
|
||||
function readPackageDetectionConfig(config: Config) {
|
||||
const packages = config.getOptional('app.experimental.packages');
|
||||
if (packages === undefined || packages === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof packages === 'string') {
|
||||
if (packages !== 'all') {
|
||||
throw new Error(
|
||||
`Invalid app.experimental.packages mode, got '${packages}', expected 'all'`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof packages !== 'object' || Array.isArray(packages)) {
|
||||
throw new Error(
|
||||
"Invalid config at 'app.experimental.packages', expected object",
|
||||
);
|
||||
}
|
||||
const packagesConfig = new ConfigReader(
|
||||
packages,
|
||||
'app.experimental.packages',
|
||||
);
|
||||
|
||||
return {
|
||||
include: packagesConfig.getOptionalStringArray('include'),
|
||||
exclude: packagesConfig.getOptionalStringArray('exclude'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function getAvailableFeatures(config: Config): FrontendFeature[] {
|
||||
const discovered = (
|
||||
window as { '__@backstage/discovered__'?: DiscoveryGlobal }
|
||||
)['__@backstage/discovered__'];
|
||||
|
||||
const detection = readPackageDetectionConfig(config);
|
||||
if (!detection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
discovered?.modules
|
||||
.filter(({ name }) => {
|
||||
if (detection.exclude?.includes(name)) {
|
||||
return false;
|
||||
}
|
||||
if (detection.include && !detection.include.includes(name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(m => m.default)
|
||||
.filter(isBackstageFeature) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function isBackstageFeature(obj: unknown): obj is FrontendFeature {
|
||||
if (obj !== null && typeof obj === 'object' && '$$type' in obj) {
|
||||
return (
|
||||
obj.$$type === '@backstage/FrontendPlugin' ||
|
||||
obj.$$type === '@backstage/FrontendModule' ||
|
||||
// TODO: Remove this once the old plugin type and extension overrides
|
||||
// are no longer supported
|
||||
obj.$$type === '@backstage/BackstagePlugin' ||
|
||||
obj.$$type === '@backstage/ExtensionOverrides'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -18,5 +18,5 @@ export {
|
||||
createApp,
|
||||
createSpecializedApp,
|
||||
type CreateAppFeatureLoader,
|
||||
} from './createApp';
|
||||
} from './createSpecializedApp';
|
||||
export * from './types';
|
||||
|
||||
Reference in New Issue
Block a user