frontend-app-api: add prepareSpecializedApp two-phase app wiring
This adds a new prepare/finalize app wiring flow that renders sign-in first and finalizes the full app after identity capture, while keeping createSpecializedApp as a deprecated wrapper. It also updates frontend-defaults/createApp to use the same flow and includes test and API report updates. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -16,8 +16,11 @@
|
||||
|
||||
import {
|
||||
AppTreeApi,
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createApiRef,
|
||||
createExtensionDataRef,
|
||||
createExtension,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
@@ -30,9 +33,17 @@ import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { mockApis, renderWithEffects } from '@backstage/test-utils';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
IdentityApi,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { default as appPluginOriginal } from '@backstage/plugin-app';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ComponentType, useState, useEffect } from 'react';
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
describe('createApp', () => {
|
||||
const appPlugin = appPluginOriginal.withOverrides({
|
||||
@@ -84,6 +95,98 @@ describe('createApp', () => {
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide app APIs to sign-in pages before finalization', async () => {
|
||||
const signInApiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.sign-in-api',
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: signInApiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'ok' }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const api = useApi(signInApiRef);
|
||||
return <div>Sign In API: {api.value}</div>;
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Sign In API: ok'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide feature flags to sign-in pages before finalization', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const flagsApi = useApi(featureFlagsApiRef);
|
||||
return (
|
||||
<div>
|
||||
Flags:{' '}
|
||||
{flagsApi
|
||||
.getRegisteredFlags()
|
||||
.map(flag => flag.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Flags: test-flag'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
@@ -283,9 +386,7 @@ describe('createApp', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should warn about unknown extension config', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
it('should allow unknown extension config if the flag is set', async () => {
|
||||
const app = createApp({
|
||||
features: [
|
||||
appPlugin,
|
||||
@@ -302,6 +403,7 @@ describe('createApp', () => {
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
allowUnknownExtensionConfig: true,
|
||||
configLoader: async () => ({
|
||||
config: mockApis.config({
|
||||
data: {
|
||||
@@ -317,12 +419,6 @@ describe('createApp', () => {
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
@@ -428,9 +524,6 @@ describe('createApp', () => {
|
||||
<app-root-element:app/alert-display out=[core.reactElement] />
|
||||
<app-root-element:app/dialog-display out=[core.reactElement] />
|
||||
]
|
||||
signInPage [
|
||||
<sign-in-page:app />
|
||||
]
|
||||
</app/root>
|
||||
]
|
||||
</app>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JSX, lazy, ReactNode, Suspense } from 'react';
|
||||
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
|
||||
import {
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
FrontendFeatureLoader,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
@@ -29,8 +30,8 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
CreateAppRouteBinder,
|
||||
createSpecializedApp,
|
||||
ExtensionFactoryMiddleware,
|
||||
prepareSpecializedApp,
|
||||
PreparedSpecializedApp,
|
||||
FrontendPluginInfoResolver,
|
||||
} from '@backstage/frontend-app-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
@@ -58,6 +59,17 @@ export interface CreateAppOptions {
|
||||
* Advanced, more rarely used options.
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* If set to true, the system will silently accept and move on if
|
||||
* encountering config for extensions that do not exist. The default is to
|
||||
* reject such config to help catch simple mistakes.
|
||||
*
|
||||
* This flag can be useful in some scenarios where you have a dynamic set of
|
||||
* extensions enabled at different times, but also increases the risk of
|
||||
* accidentally missing e.g. simple typos in your config.
|
||||
*/
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
|
||||
/**
|
||||
* Sets a custom config loader, replacing the builtin one.
|
||||
*
|
||||
@@ -119,23 +131,22 @@ export function createApp(options?: CreateAppOptions): {
|
||||
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [appPlugin, ...loadedFeatures],
|
||||
config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: options?.advanced,
|
||||
});
|
||||
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return { default: () => errorPage };
|
||||
if (preparedApp.getSignIn()) {
|
||||
return {
|
||||
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
|
||||
};
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
return { default: () => rootEl };
|
||||
return {
|
||||
default: () => renderFinalizedApp(preparedApp.finalize()),
|
||||
};
|
||||
}
|
||||
|
||||
const LazyApp = lazy(appLoader);
|
||||
@@ -150,3 +161,60 @@ export function createApp(options?: CreateAppOptions): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PreparedAppRoot(props: {
|
||||
preparedApp: PreparedSpecializedApp;
|
||||
}): JSX.Element {
|
||||
const signIn = props.preparedApp.getSignIn();
|
||||
const [finalizeError, setFinalizeError] = useState<Error>();
|
||||
const [finalizedApp, setFinalizedApp] = useState(() => {
|
||||
if (!signIn) {
|
||||
return props.preparedApp.finalize();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (signIn) {
|
||||
void signIn.complete
|
||||
.then(async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizedApp(props.preparedApp.finalize());
|
||||
})
|
||||
.catch(error => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizeError(error);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.preparedApp, signIn]);
|
||||
|
||||
if (finalizeError) {
|
||||
throw finalizeError;
|
||||
}
|
||||
|
||||
if (!finalizedApp) {
|
||||
return signIn!.element;
|
||||
}
|
||||
|
||||
return renderFinalizedApp(finalizedApp);
|
||||
}
|
||||
|
||||
function renderFinalizedApp(
|
||||
app: ReturnType<PreparedSpecializedApp['finalize']>,
|
||||
) {
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return errorPage;
|
||||
}
|
||||
|
||||
return app.tree.root.instance!.getData(coreExtensionData.reactElement)!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user