diff --git a/.changeset/dry-beers-shake.md b/.changeset/dry-beers-shake.md new file mode 100644 index 0000000000..59ff5ead54 --- /dev/null +++ b/.changeset/dry-beers-shake.md @@ -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`. diff --git a/.changeset/four-parents-buy.md b/.changeset/four-parents-buy.md new file mode 100644 index 0000000000..fc925cf6d1 --- /dev/null +++ b/.changeset/four-parents-buy.md @@ -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`. diff --git a/beps/0002-dynamic-frontend-plugins/README.md b/beps/0002-dynamic-frontend-plugins/README.md index 34c6e2d435..db223c959d 100644 --- a/beps/0002-dynamic-frontend-plugins/README.md +++ b/beps/0002-dynamic-frontend-plugins/README.md @@ -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: diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 532971d124..d1c3820f09 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -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({ diff --git a/docs/frontend-system/architecture/25-extension-overrides.md b/docs/frontend-system/architecture/25-extension-overrides.md index d47e2697a9..785843f6e0 100644 --- a/docs/frontend-system/architecture/25-extension-overrides.md +++ b/docs/frontend-system/architecture/25-extension-overrides.md @@ -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({ diff --git a/docs/frontend-system/architecture/36-routes.md b/docs/frontend-system/architecture/36-routes.md index e684932414..2a9b372b1c 100644 --- a/docs/frontend-system/architecture/36-routes.md +++ b/docs/frontend-system/architecture/36-routes.md @@ -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'; diff --git a/docs/frontend-system/building-apps/01-index.md b/docs/frontend-system/building-apps/01-index.md index 16aacc1185..5b5298a2c5 100644 --- a/docs/frontend-system/building-apps/01-index.md +++ b/docs/frontend-system/building-apps/01-index.md @@ -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 diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index ddec37a4ea..96e7dd2bd5 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -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. diff --git a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx index 34d2937425..4c2acd35a6 100644 --- a/packages/core-compat-api/src/collectLegacyRoutes.test.tsx +++ b/packages/core-compat-api/src/collectLegacyRoutes.test.tsx @@ -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', () => { , ); - render(createSpecializedApp({ features }).createRoot()); + renderInTestApp(
, { features }); await expect( screen.findByText('plugins: app, test'), diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index e8fa7572b7..e102d6bf20 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -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 = < diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 66f68bb9e9..52078a4ff7 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -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" diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx new file mode 100644 index 0000000000..e98014f273 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -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(
Test
)], + }), + ], + }), + ], + }); + + 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(
Test 1
), + ], + }), + ], + }), + createFrontendPlugin({ + id: 'test', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Test 2
), + ], + }), + ], + }), + ], + }); + + 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( +
Test {apis.get(configApiRef)!.getString('test')}
, + ), + ], + }), + ], + }), + ], + }); + + 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( +
+ flags: + {apis + .get(featureFlagsApiRef)! + .getRegisteredFlags() + .map(f => `${f.pluginId}=${f.name}`) + .join(',')} +
, + ), + ], + }), + 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(
)]; + }, + }), + ], + }), + ], + }); + + expect(String(appTreeApi!.getTree().tree.root)).toMatchInlineSnapshot(` + " + app [ + + ] + " + `); + }); + + 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( + + + {inputs.children.map(i => ( + + {i.get(coreExtensionData.reactElement)} + + ))} + + , + ), + ]; + }, + }), + createExtension({ + name: 'child', + attachTo: { id: 'a/parent', input: 'children' }, + output: [coreExtensionData.reactElement], + factory: () => { + const Component = () => { + const link = useRouteRef(extRouteRef); + return
link: {link?.() ?? 'none'}
; + }; + return [coreExtensionData.reactElement()]; + }, + }), + ], + }); + 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(
), + 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(); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx similarity index 74% rename from packages/frontend-app-api/src/wiring/createApp.tsx rename to packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 591dee9a00..27f1d52822 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -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 ( - - - - ); - }, - }; -} - // 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; diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index bcf623d740..d76055f63b 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -18,5 +18,5 @@ export { createApp, createSpecializedApp, type CreateAppFeatureLoader, -} from './createApp'; +} from './createSpecializedApp'; export * from './types'; diff --git a/packages/frontend-defaults/.eslintrc.js b/packages/frontend-defaults/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/frontend-defaults/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/frontend-defaults/README.md b/packages/frontend-defaults/README.md new file mode 100644 index 0000000000..2cca17ee85 --- /dev/null +++ b/packages/frontend-defaults/README.md @@ -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) diff --git a/packages/frontend-defaults/api-report.md b/packages/frontend-defaults/api-report.md new file mode 100644 index 0000000000..f1ef153e43 --- /dev/null +++ b/packages/frontend-defaults/api-report.md @@ -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[]; + }>; +} +``` diff --git a/packages/frontend-defaults/catalog-info.yaml b/packages/frontend-defaults/catalog-info.yaml new file mode 100644 index 0000000000..cd0ac9b79a --- /dev/null +++ b/packages/frontend-defaults/catalog-info.yaml @@ -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 diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json new file mode 100644 index 0000000000..c70bb42424 --- /dev/null +++ b/packages/frontend-defaults/package.json @@ -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" + } +} diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx similarity index 97% rename from packages/frontend-app-api/src/wiring/createApp.test.tsx rename to packages/frontend-defaults/src/createApp.test.tsx index 1a92a0eb7d..1954554816 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -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(); }); diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx new file mode 100644 index 0000000000..688e01534a --- /dev/null +++ b/packages/frontend-defaults/src/createApp.tsx @@ -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 ( + + + + ); + }, + }; +} diff --git a/packages/frontend-app-api/src/wiring/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts similarity index 100% rename from packages/frontend-app-api/src/wiring/discovery.test.ts rename to packages/frontend-defaults/src/discovery.test.ts diff --git a/packages/frontend-app-api/src/wiring/discovery.ts b/packages/frontend-defaults/src/discovery.ts similarity index 99% rename from packages/frontend-app-api/src/wiring/discovery.ts rename to packages/frontend-defaults/src/discovery.ts index 6f9d2e2d16..e04960dc48 100644 --- a/packages/frontend-app-api/src/wiring/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -53,7 +53,7 @@ function readPackageDetectionConfig(config: Config) { } /** - * @public + * @internal */ export function getAvailableFeatures(config: Config): FrontendFeature[] { const discovered = ( diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts new file mode 100644 index 0000000000..913cd3934b --- /dev/null +++ b/packages/frontend-defaults/src/index.ts @@ -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'; diff --git a/packages/frontend-defaults/src/setupTests.ts b/packages/frontend-defaults/src/setupTests.ts new file mode 100644 index 0000000000..91af6695ac --- /dev/null +++ b/packages/frontend-defaults/src/setupTests.ts @@ -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'; diff --git a/scripts/verify-local-dependencies.js b/scripts/verify-local-dependencies.js index d3d76396e5..045abcff57 100755 --- a/scripts/verify-local-dependencies.js +++ b/scripts/verify-local-dependencies.js @@ -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', diff --git a/yarn.lock b/yarn.lock index 53620b2868..22062e72bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"