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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': minor
---
Initial release of this package, which provides a default app setup through the `createApp` function. This replaces the existing `createApp` method from `@backstage/frontend-app-api`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': minor
---
**BREAKING**: The `createSpecializedApp` function now creates a bare-bones app without any of the default app structure or APIs. To re-introduce this functionality if you need to use `createSpecializedApp` you can install the `app` plugin from `@backstage/plugin-app`.
+2 -2
View File
@@ -301,7 +301,7 @@ An example of [integration with scalprum](https://github.com/backstage/backstage
**Dynamic Feature configuration**
The dynamic remote loading can be added directly into the [`createApp`](https://github.com/backstage/backstage/blob/master/packages/frontend-app-api/src/wiring/createApp.tsx#L234) function.
The dynamic remote loading can be added directly into the [`createApp`](https://backstage.io/docs/reference/frontend-defaults.createapp) function.
The current `feature` type can be expanded with a `DynamicFrontendFeature` type:
@@ -351,7 +351,7 @@ const scalprum = initialize({
});
```
Because the [`appLoader`](https://github.com/backstage/backstage/blob/master/packages/frontend-app-api/src/wiring/createApp.tsx#L193) is already async, it is a perfect place to load the plugin registry and init the dynamic plugins.
Because the [`appLoader`](https://backstage.io/docs/reference/frontend-defaults.createapp) is already async, it is a perfect place to load the plugin registry and init the dynamic plugins.
Initializing the dynamic feature is just a case of mapping the `DynamicFrontendFeature` to `FrontendFeature` via Scalprum:
+1 -1
View File
@@ -16,7 +16,7 @@ Below is a simple example of how to create and render an app instance:
```ts
import ReactDOM from 'react-dom/client';
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
// Create your app instance
const app = createApp({
@@ -295,7 +295,7 @@ export default createExtensionOverrides({
Assuming the above code resides in the `@internal/search-page` package, you can install it in your app like this:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
import searchPageOverride from '@internal/search-page';
const app = createApp({
@@ -256,7 +256,7 @@ app:
We also have the ability to express this in code as an option to `createApp`, but you of course only need to use one of these two methods:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
import catalog from '@backstage/plugin-catalog';
import scaffolder from '@backstage/plugin-scaffolder';
@@ -30,13 +30,13 @@ The created-app is currently templated for legacy frontend system applications,
## The app instance
The starting point of a frontend app is the `createApp` function, which accepts a single options object as its only parameter. It is imported from `@backstage/frontend-app-api`, which is where you will find most of the common APIs for building apps.
The starting point of a frontend app is the `createApp` function, which accepts a single options object as its only parameter. It is imported from `@backstage/frontend-defaults`, which is where you will find most of the common APIs for building apps.
This is how to create a minimal app:
```tsx title="in src/index.ts"
import ReactDOM from 'react-dom/client';
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
// Create your app instance
@@ -87,7 +87,7 @@ Previously you would customize the application routes, components, apis, sidebar
A manual installation is required if your packages are not discovered automatically, either because you are not using `@backstage/cli` to build your application or because the features are defined in local modules in the app package. In order to manually install a feature, you must import it and pass it to the `createApp` function:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
// This plugin was create as a local module in the app
import { somePlugin } from './plugins';
@@ -104,10 +104,10 @@ You can also pass overrides to the features array, for more details, please read
### Using an async features loader
In case you need to perform asynchronous operations before passing features to the `createApp` function, define a [feature loader](https://backstage.io/docs/reference/frontend-app-api.createappfeatureloader/) object and pass it to the `features` option:
In case you need to perform asynchronous operations before passing features to the `createApp` function, define a [feature loader](https://backstage.io/docs/reference/frontend-defaults.createappfeatureloader/) object and pass it to the `features` option:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
const app = createApp({
features: {
@@ -129,7 +129,7 @@ export default app.createRoot();
In some cases we want to load our configuration from a backend server and to do so, you can pass an callback to the `configLoader` option when calling the `createApp` function, the callback should return a promise of an object with the config object:
```tsx title="packages/app/src/App.tsx"
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
import { getConfigFromServer } from './utils';
// Example lazy loading the app configuration
@@ -18,7 +18,7 @@ The first step in migrating an app is to switch out the `createApp` function for
// highlight-remove-next-line
import { createApp } from '@backstage/app-defaults';
// highlight-add-next-line
import { createApp } from '@backstage/frontend-app-api';
import { createApp } from '@backstage/frontend-defaults';
```
This immediate switch will lead to a lot of breakages that we need to fix.
@@ -38,8 +38,8 @@ import {
createRouteRef,
useApp,
} from '@backstage/core-plugin-api';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/frontend-test-utils';
describe('collectLegacyRoutes', () => {
it('should collect legacy routes', () => {
@@ -281,7 +281,7 @@ describe('collectLegacyRoutes', () => {
</FlatRoutes>,
);
render(createSpecializedApp({ features }).createRoot());
renderInTestApp(<div />, { features });
await expect(
screen.findByText('plugins: app, test'),
+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"
@@ -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;
@@ -18,5 +18,5 @@ export {
createApp,
createSpecializedApp,
type CreateAppFeatureLoader,
} from './createApp';
} from './createSpecializedApp';
export * from './types';
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+9
View File
@@ -0,0 +1,9 @@
# @backstage/frontend-defaults
**The [new frontend system](https://backstage.io/docs/frontend-system/) that this package is part of is in alpha, and we do not yet recommend using it for production deployments**
This package provides the high-level APIs used to create Backstage frontend applications with the default setup. For more information, see the [documentation on how to build Backstage frontend applications](https://backstage.io/docs/frontend-system/building-apps/index).
## Documentation
- [Backstage Documentation](https://backstage.io/docs)
+31
View File
@@ -0,0 +1,31 @@
## API Report File for "@backstage/frontend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { FrontendFeature } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
// @public
export function createApp(options?: {
features?: (FrontendFeature | CreateAppFeatureLoader)[];
configLoader?: () => Promise<{
config: ConfigApi;
}>;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
loadingComponent?: ReactNode;
}): {
createRoot(): JSX_2.Element;
};
// @public
export interface CreateAppFeatureLoader {
getLoaderName(): string;
load(options: { config: ConfigApi }): Promise<{
features: FrontendFeature[];
}>;
}
```
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-frontend-defaults
title: '@backstage/frontend-defaults'
spec:
lifecycle: experimental
type: backstage-web-library
owner: maintainers
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/frontend-defaults",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/frontend-defaults"
},
"backstage": {
"role": "web-library"
},
"sideEffects": false,
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^15.0.0"
},
"files": [
"dist"
],
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
}
}
@@ -19,7 +19,6 @@ import {
appTreeApiRef,
coreExtensionData,
createExtension,
createExtensionOverrides,
PageBlueprint,
createFrontendPlugin,
ThemeBlueprint,
@@ -176,7 +175,13 @@ describe('createApp', () => {
const app = createApp({
configLoader: async () => ({ config: new MockConfigApi({}) }),
features: [
appPlugin,
appPlugin.withOverrides({
extensions: [
appPlugin
.getExtension('app/root')
.override({ disabled: true, factory: orig => orig() }),
],
}),
createFrontendPlugin({
id: 'test',
featureFlags: [{ name: 'test-1' }],
@@ -203,18 +208,10 @@ describe('createApp', () => {
}),
],
}),
createExtensionOverrides({
createFrontendPlugin({
id: 'other',
featureFlags: [{ name: 'test-2' }],
extensions: [
createExtension({
namespace: 'app',
name: 'root',
attachTo: { id: 'app', input: 'root' },
disabled: true,
output: [],
factory: () => [],
}),
],
extensions: [],
}),
],
});
@@ -222,7 +219,7 @@ describe('createApp', () => {
await renderWithEffects(app.createRoot());
await expect(
screen.findByText("Flags: test-1 from 'test', test-2 from ''"),
screen.findByText("Flags: test-1 from 'test', test-2 from 'other'"),
).resolves.toBeInTheDocument();
});
@@ -0,0 +1,122 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { JSX, ReactNode } from 'react';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { stringifyError } from '@backstage/errors';
// 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 { getAvailableFeatures } from './discovery';
import { ConfigReader } from '@backstage/config';
import appPlugin from '@backstage/plugin-app';
import {
CreateAppRouteBinder,
FrontendFeature,
createSpecializedApp,
} from '@backstage/frontend-app-api';
/**
* 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[];
}>;
}
/**
* Creates a new Backstage frontend app instance. See https://backstage.io/docs/frontend-system/building-apps/index
*
* @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: [appPlugin, ...discoveredFeatures, ...providedFeatures],
bindRoutes: options?.bindRoutes,
}).createRoot();
return { default: () => app };
}
return {
createRoot() {
const LazyApp = React.lazy(appLoader);
return (
<React.Suspense fallback={suspenseFallback}>
<LazyApp />
</React.Suspense>
);
},
};
}
@@ -53,7 +53,7 @@ function readPackageDetectionConfig(config: Config) {
}
/**
* @public
* @internal
*/
export function getAvailableFeatures(config: Config): FrontendFeature[] {
const discovered = (
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* APIs for creating Backstage apps with a default setup.
*
* @packageDocumentation
*/
export { createApp, type CreateAppFeatureLoader } from './createApp';
@@ -0,0 +1,17 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
+2
View File
@@ -60,6 +60,7 @@ const roleRules = [
targetRole: 'frontend-plugin',
except: [
// TODO(freben): Address these
'@backstage/frontend-defaults',
'@backstage/frontend-app-api',
'@backstage/frontend-test-utils',
'@backstage/plugin-api-docs',
@@ -75,6 +76,7 @@ const roleRules = [
'@backstage/app-defaults',
'@backstage/core-compat-api',
'@backstage/dev-utils',
'@backstage/frontend-defaults',
'@backstage/frontend-app-api',
'@backstage/frontend-test-utils',
'@backstage/test-utils',
+21 -4
View File
@@ -4426,17 +4426,14 @@ __metadata:
"@backstage/cli": "workspace:^"
"@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/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@material-ui/core": ^4.12.4
"@material-ui/icons": ^4.11.3
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^15.0.0
"@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
@@ -4447,6 +4444,26 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/frontend-defaults@workspace:^, @backstage/frontend-defaults@workspace:packages/frontend-defaults":
version: 0.0.0-use.local
resolution: "@backstage/frontend-defaults@workspace:packages/frontend-defaults"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^15.0.0
"@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0 || ^18.0.0
languageName: unknown
linkType: soft
"@backstage/frontend-plugin-api@npm:^0.7.0":
version: 0.7.0
resolution: "@backstage/frontend-plugin-api@npm:0.7.0"