frontend-app-api: extract createApp out into frontend-defaults

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-28 14:02:26 +02:00
parent fc66073fa4
commit 7c80650a1e
27 changed files with 643 additions and 169 deletions
+6 -19
View File
@@ -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 = <
+2 -5
View File
@@ -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();
});
});
@@ -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';