Merge pull request #33356 from backstage/conditional-extensions
frontend-app-api: add phased app preparation
This commit is contained in:
@@ -23,6 +23,9 @@ import {
|
||||
PageBlueprint,
|
||||
FrontendPluginInfo,
|
||||
useAppNode,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
coreExtensionData,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
|
||||
const page1Link = useRouteRef(page1RouteRef);
|
||||
return (
|
||||
<div>
|
||||
op
|
||||
<h1>Example Pages Plugin</h1>
|
||||
<h2>Navigation</h2>
|
||||
{page1Link && (
|
||||
<div>
|
||||
<Link to={page1Link()}>Page 1</Link>
|
||||
@@ -83,6 +87,54 @@ const IndexPage = PageBlueprint.make({
|
||||
<div>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</div>
|
||||
|
||||
<h2>Permission Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>if</code> predicate using permissions. They will
|
||||
only appear when the user has the required permissions.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/permission-gated-example">
|
||||
Permission Gated Example
|
||||
</Link>{' '}
|
||||
— requires <code>catalog.entity.create</code>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/permission-card-example">
|
||||
Permission Card Example
|
||||
</Link>{' '}
|
||||
— a page that is always visible, but individual cards on it are
|
||||
toggled by permissions
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Feature Flag Enablement Examples</h2>
|
||||
<p>
|
||||
The following pages demonstrate conditional extension enablement
|
||||
via the <code>if</code> predicate. They will only appear in the
|
||||
router tree when their conditions are satisfied. Toggle the
|
||||
relevant feature flags in <Link to="/settings">Settings</Link>,
|
||||
then refresh the app to see the pages appear.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/feature-flag-example">Feature Flag Example</Link> —
|
||||
requires the <code>experimental-features</code> flag
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/all-flags-example">All Flags Example</Link> —
|
||||
requires <em>both</em> <code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> (<code>$all</code>)
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/any-flag-example">Any Flag Example</Link> — requires{' '}
|
||||
<em>either</em> <code>experimental-features</code> or{' '}
|
||||
<code>beta-access</code> (<code>$any</code>)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<PluginInfo />
|
||||
</div>
|
||||
);
|
||||
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled only when a single feature flag is active.
|
||||
//
|
||||
// The `if` predicate is evaluated once at app startup (before the router
|
||||
// tree is built), so this page simply won't exist in the app until the flag is
|
||||
// toggled and the page is refreshed.
|
||||
//
|
||||
// To test: enable the 'experimental-features' flag in Settings, then refresh.
|
||||
const FeatureFlagPage = PageBlueprint.make({
|
||||
name: 'featureFlagExample',
|
||||
params: {
|
||||
path: '/feature-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Feature Flag Enabled Page</h1>
|
||||
<p>
|
||||
This page is only present in the app when the{' '}
|
||||
<code>experimental-features</code> feature flag is active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a simple{' '}
|
||||
<code>
|
||||
{'{ featureFlags: { $contains: "experimental-features" } }'}
|
||||
</code>{' '}
|
||||
predicate.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: { featureFlags: { $contains: 'experimental-features' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when ALL of several feature flags are active.
|
||||
//
|
||||
// The $all operator requires every nested predicate to be satisfied. This page
|
||||
// won't appear unless both 'experimental-features' and 'advanced-features' are
|
||||
// enabled at the same time.
|
||||
//
|
||||
// To test: enable BOTH flags in Settings, then refresh.
|
||||
const AllFlagsPage = PageBlueprint.make({
|
||||
name: 'allFlagsExample',
|
||||
params: {
|
||||
path: '/all-flags-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>All Flags Required Page</h1>
|
||||
<p>
|
||||
This page requires <em>both</em>{' '}
|
||||
<code>experimental-features</code> and{' '}
|
||||
<code>advanced-features</code> to be active simultaneously.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$all</code> predicate to AND the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page enabled when ANY one of several feature flags is active.
|
||||
//
|
||||
// The $any operator is satisfied as soon as at least one nested predicate
|
||||
// matches. Enabling either 'experimental-features' or 'beta-access' will make
|
||||
// this page appear.
|
||||
//
|
||||
// To test: enable at least one of the two flags in Settings, then refresh.
|
||||
const AnyFlagPage = PageBlueprint.make({
|
||||
name: 'anyFlagExample',
|
||||
params: {
|
||||
path: '/any-flag-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Any Flag Sufficient Page</h1>
|
||||
<p>
|
||||
This page appears when <em>either</em>{' '}
|
||||
<code>experimental-features</code> or <code>beta-access</code> is
|
||||
active.
|
||||
</p>
|
||||
<p>
|
||||
It uses a <code>$any</code> predicate to OR the two conditions
|
||||
together.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Blueprint for cards that attach to the PermissionCardPage below.
|
||||
//
|
||||
// Each card receives a title and description and renders a simple bordered card.
|
||||
// Individual card instances can be selectively enabled via the `if`
|
||||
// predicate, so only the cards the user is allowed to see will be instantiated.
|
||||
const PermissionExampleCardBlueprint = createExtensionBlueprint({
|
||||
kind: 'permission-example-card',
|
||||
attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string; description: string }) {
|
||||
yield coreExtensionData.reactElement(
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{params.title}</h3>
|
||||
<p style={{ marginBottom: 0 }}>{params.description}</p>
|
||||
</div>,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Example: Page with cards that are individually toggled by permissions.
|
||||
//
|
||||
// The page itself is always present. What changes is which cards are
|
||||
// instantiated inside it — each card declares its own `enabled` predicate
|
||||
// and is only wired into the page if that predicate is satisfied at startup.
|
||||
//
|
||||
// To test: make sure you do NOT have the catalog.entity.create permission and
|
||||
// refresh the page — the "Restricted Card" below should disappear.
|
||||
const PermissionCardPage = PageBlueprint.makeWithOverrides({
|
||||
name: 'permissionCardExample',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement]),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/permission-card-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
const cards = inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission-Gated Card Example</h1>
|
||||
<p>
|
||||
This page is always visible. The cards below are individually
|
||||
gated — each one declares its own{' '}
|
||||
<code>{'if: { permissions: { $contains: "..." } }'}</code>{' '}
|
||||
predicate. Cards whose predicate fails are never instantiated,
|
||||
so they simply won't appear here.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
{cards.length > 0 ? (
|
||||
cards
|
||||
) : (
|
||||
<p>
|
||||
No cards are visible — you may lack the required
|
||||
permissions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Always-visible card — no predicate, every user sees this.
|
||||
const PublicCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'public',
|
||||
params: {
|
||||
title: 'Public Card',
|
||||
description: 'This card is visible to everyone regardless of permissions.',
|
||||
},
|
||||
});
|
||||
|
||||
// Permission-gated card — only instantiated when the user has
|
||||
// the catalog.entity.create permission.
|
||||
const RestrictedCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'restricted',
|
||||
params: {
|
||||
title: 'Restricted Card',
|
||||
description:
|
||||
'This card is only visible to users who have the catalog.entity.create permission.',
|
||||
},
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
// Feature flag-gated card — only instantiated when the user has
|
||||
// the experimental-card FF enabled.
|
||||
const FeatureFlagCard = PermissionExampleCardBlueprint.make({
|
||||
name: 'feature-flag',
|
||||
params: {
|
||||
title: 'Feature Flagged Card',
|
||||
description: 'Visible only with the experimental-card FF active.',
|
||||
},
|
||||
if: { featureFlags: { $contains: 'experimental-card' } },
|
||||
});
|
||||
|
||||
// Example: Page enabled only when the user is allowed to create catalog entities.
|
||||
//
|
||||
// The `if` predicate is evaluated once at app startup (after sign-in),
|
||||
// so this page simply won't exist in the router tree if the user lacks the
|
||||
// required permission.
|
||||
const PermissionGatedPage = PageBlueprint.make({
|
||||
name: 'permissionGatedExample',
|
||||
params: {
|
||||
path: '/permission-gated-example',
|
||||
loader: async () => {
|
||||
const Component = () => {
|
||||
const indexLink = useRouteRef(indexRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<h1>Permission Gated Page</h1>
|
||||
<p>
|
||||
This page is only present when the user has the{' '}
|
||||
<code>catalog.entity.create</code> permission.
|
||||
</p>
|
||||
{indexLink && <Link to={indexLink()}>Go back</Link>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <Component />;
|
||||
},
|
||||
},
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
|
||||
export const pagesPlugin = createFrontendPlugin({
|
||||
pluginId: 'pages',
|
||||
// routes: {
|
||||
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
|
||||
externalRoutes: {
|
||||
pageX: externalPageXRouteRef,
|
||||
},
|
||||
extensions: [IndexPage, Page1, ExternalPage],
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
{ name: 'beta-access' },
|
||||
{ name: 'experimental-card' },
|
||||
],
|
||||
extensions: [
|
||||
IndexPage,
|
||||
Page1,
|
||||
ExternalPage,
|
||||
FeatureFlagPage,
|
||||
AllFlagsPage,
|
||||
AnyFlagPage,
|
||||
PermissionCardPage,
|
||||
PublicCard,
|
||||
RestrictedCard,
|
||||
PermissionGatedPage,
|
||||
FeatureFlagCard,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => {
|
||||
"getExtension": [Function],
|
||||
"icon": undefined,
|
||||
"id": "test",
|
||||
"if": undefined,
|
||||
"info": [Function],
|
||||
"infoOptions": undefined,
|
||||
"pluginId": "test",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import { Progress } from './Progress';
|
||||
|
||||
@@ -23,4 +24,11 @@ describe('<Progress />', () => {
|
||||
const { queryByTestId } = await renderInTestApp(<Progress />);
|
||||
expect(queryByTestId('progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides an accessible name for the progress bar', async () => {
|
||||
await renderInTestApp(<Progress />);
|
||||
expect(
|
||||
await screen.findByRole('progressbar', { name: 'Loading' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
export function Progress(props: PropsWithChildren<LinearProgressProps>) {
|
||||
const { 'aria-label': ariaLabel, ...progressProps } = props;
|
||||
const theme = useTheme();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren<LinearProgressProps>) {
|
||||
}, [theme.transitions.duration.short]);
|
||||
|
||||
return isVisible ? (
|
||||
<LinearProgress {...props} data-testid="progress" />
|
||||
<LinearProgress
|
||||
{...progressProps}
|
||||
aria-label={ariaLabel ?? 'Loading'}
|
||||
data-testid="progress"
|
||||
/>
|
||||
) : (
|
||||
<Box display="none" data-testid="progress" />
|
||||
);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-defaults": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
@@ -127,6 +128,26 @@ export type AppErrorTypes = {
|
||||
existingPluginId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
bootstrapNode: AppNode;
|
||||
pluginId: string;
|
||||
bootstrapPluginId: string;
|
||||
};
|
||||
};
|
||||
ROUTE_DUPLICATE: {
|
||||
context: {
|
||||
routeId: string;
|
||||
@@ -144,6 +165,12 @@ export type AppErrorTypes = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type BootstrapSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
tree: AppTree;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
TExternalRoutes extends {
|
||||
@@ -157,14 +184,12 @@ export type CreateAppRouteBinder = <
|
||||
>,
|
||||
) => void;
|
||||
|
||||
// @public
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
// @public @deprecated
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type CreateSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
@@ -172,8 +197,8 @@ export type CreateSpecializedAppOptions = {
|
||||
advanced?: {
|
||||
apis?: ApiHolder;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
@@ -190,6 +215,14 @@ export type ExtensionFactoryMiddleware = (
|
||||
},
|
||||
) => Iterable<ExtensionDataValue<any, any>>;
|
||||
|
||||
// @public
|
||||
export type FinalizedSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
sessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type FrontendPluginInfoResolver = (ctx: {
|
||||
packageJson(): Promise<JsonObject | undefined>;
|
||||
@@ -203,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
|
||||
}) => Promise<{
|
||||
info: FrontendPluginInfo;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(): FinalizedSpecializedApp;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function prepareSpecializedApp(
|
||||
options?: PrepareSpecializedAppOptions,
|
||||
): PreparedSpecializedApp;
|
||||
|
||||
// @public
|
||||
export type PrepareSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
advanced?: {
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SpecializedAppSessionState = {
|
||||
$$type: '@backstage/SpecializedAppSessionState';
|
||||
};
|
||||
```
|
||||
|
||||
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('if predicate', () => {
|
||||
function makeNodeWithEnabled(
|
||||
enabled: AppNodeSpec['if'],
|
||||
disabled = false,
|
||||
): AppNode {
|
||||
const ext = resolveExtensionDefinition(
|
||||
createExtension({
|
||||
attachTo: { id: 'ignored', input: 'ignored' },
|
||||
output: [testDataRef],
|
||||
factory: () => [testDataRef('value')],
|
||||
}),
|
||||
{ namespace: 'test-ext' },
|
||||
);
|
||||
return {
|
||||
spec: {
|
||||
id: ext.id,
|
||||
attachTo: ext.attachTo,
|
||||
disabled,
|
||||
if: enabled,
|
||||
extension: ext as Extension<unknown, unknown>,
|
||||
plugin: createFrontendPlugin({ pluginId: 'app' }),
|
||||
},
|
||||
edges: { attachments: new Map() },
|
||||
};
|
||||
}
|
||||
|
||||
it('should skip a node when the predicate is not satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should instantiate a node when the predicate is satisfied', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['the-flag'],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
expect(tree.root.instance?.getData(testDataRef)).toBe('value');
|
||||
});
|
||||
|
||||
it('should support $all operator across multiple flags', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'flag-a' } },
|
||||
{ featureFlags: { $contains: 'flag-b' } },
|
||||
],
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
|
||||
// Only one flag active — should not instantiate
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a'],
|
||||
});
|
||||
expect(tree.root.instance).toBeUndefined();
|
||||
|
||||
// Both flags active — should instantiate
|
||||
const tree2 = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
|
||||
featureFlags: ['flag-a', 'flag-b'],
|
||||
});
|
||||
expect(tree2.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
|
||||
const node = makeNodeWithEnabled(undefined);
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
|
||||
featureFlags: [],
|
||||
});
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
|
||||
it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
|
||||
const node = makeNodeWithEnabled({
|
||||
featureFlags: { $contains: 'the-flag' },
|
||||
});
|
||||
const tree = resolveAppTree('test-ext', [node.spec], collector);
|
||||
// No predicateContext passed — predicate evaluation is skipped
|
||||
instantiateAppNodeTree(tree.root, testApis, collector);
|
||||
expect(tree.root.instance).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
import { createExtensionDataContainer } from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
import { evaluateFilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
const INSTANTIATION_FAILED = new Error('Instantiation failed');
|
||||
|
||||
@@ -64,6 +65,19 @@ type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
type InstantiateAppNodeSubtreeOptions = {
|
||||
rootNode: AppNode;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
reuseExistingInstances?: boolean;
|
||||
writeNodeInstances?: boolean;
|
||||
};
|
||||
|
||||
function resolveV1InputDataMap(
|
||||
dataMap: {
|
||||
[name in string]: ExtensionDataRef;
|
||||
@@ -337,12 +351,28 @@ export function createAppNodeInstance(options: {
|
||||
apis: ApiHolder;
|
||||
attachments: ReadonlyMap<string, AppNode[]>;
|
||||
collector: ErrorCollector;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
}): AppNodeInstance | undefined {
|
||||
const { node, apis, attachments } = options;
|
||||
const collector = options.collector.child({ node });
|
||||
const { id, extension, config } = node.spec;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
|
||||
const scopedApis: ApiHolder =
|
||||
options.onMissingApi === undefined
|
||||
? apis
|
||||
: {
|
||||
get(apiRef) {
|
||||
const api = apis.get(apiRef);
|
||||
if (api === undefined) {
|
||||
options.onMissingApi?.({
|
||||
node,
|
||||
apiRefId: apiRef.id,
|
||||
});
|
||||
}
|
||||
return api;
|
||||
},
|
||||
};
|
||||
|
||||
let parsedConfig: { [x: string]: any };
|
||||
try {
|
||||
@@ -367,7 +397,7 @@ export function createAppNodeInstance(options: {
|
||||
if (internalExtension.version === 'v1') {
|
||||
const namedOutputs = internalExtension.factory({
|
||||
node,
|
||||
apis,
|
||||
apis: scopedApis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
|
||||
});
|
||||
@@ -388,7 +418,7 @@ export function createAppNodeInstance(options: {
|
||||
} else if (internalExtension.version === 'v2') {
|
||||
const context = {
|
||||
node,
|
||||
apis,
|
||||
apis: scopedApis,
|
||||
config: parsedConfig,
|
||||
inputs: resolveV2Inputs(
|
||||
internalExtension.inputs,
|
||||
@@ -500,6 +530,87 @@ export function createAppNodeInstance(options: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at the provided node, instantiate a subtree without necessarily
|
||||
* mutating the original app tree.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function instantiateAppNodeSubtree(
|
||||
options: InstantiateAppNodeSubtreeOptions,
|
||||
): AppNode | undefined {
|
||||
const instantiatedNodes = new WeakMap<AppNode, AppNode | null>();
|
||||
|
||||
function createInstance(node: AppNode): AppNode | undefined {
|
||||
if (instantiatedNodes.has(node)) {
|
||||
return instantiatedNodes.get(node) ?? undefined;
|
||||
}
|
||||
if (options.reuseExistingInstances !== false && node.instance) {
|
||||
instantiatedNodes.set(node, node);
|
||||
return node;
|
||||
}
|
||||
if (node.spec.disabled) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
options.predicateContext !== undefined &&
|
||||
node.spec.if !== undefined &&
|
||||
!evaluateFilterPredicate(node.spec.if, options.predicateContext)
|
||||
) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<string, AppNode[]>();
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
if (options.stopAtAttachment?.({ node, input })) {
|
||||
continue;
|
||||
}
|
||||
const instantiatedChildren = children.flatMap(child => {
|
||||
if (options.skipChild?.({ node, input, child })) {
|
||||
return [];
|
||||
}
|
||||
const childNode = createInstance(child);
|
||||
return childNode ? [childNode] : [];
|
||||
});
|
||||
if (instantiatedChildren.length > 0) {
|
||||
instantiatedAttachments.set(input, instantiatedChildren);
|
||||
}
|
||||
}
|
||||
|
||||
const instance = createAppNodeInstance({
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
node,
|
||||
apis: options.apis,
|
||||
attachments: instantiatedAttachments,
|
||||
collector: options.collector,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
if (!instance) {
|
||||
instantiatedNodes.set(node, null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (options.writeNodeInstances === false) {
|
||||
const detachedNode: AppNode = {
|
||||
spec: node.spec,
|
||||
edges: node.edges,
|
||||
instance,
|
||||
};
|
||||
instantiatedNodes.set(node, detachedNode);
|
||||
return detachedNode;
|
||||
}
|
||||
|
||||
(node as Mutable<AppNode>).instance = instance;
|
||||
instantiatedNodes.set(node, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
return createInstance(options.rootNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
|
||||
* @internal
|
||||
@@ -509,40 +620,45 @@ export function instantiateAppNodeTree(
|
||||
apis: ApiHolder,
|
||||
collector: ErrorCollector,
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
|
||||
): boolean {
|
||||
function createInstance(node: AppNode): AppNodeInstance | undefined {
|
||||
if (node.instance) {
|
||||
return node.instance;
|
||||
}
|
||||
if (node.spec.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const instantiatedAttachments = new Map<string, AppNode[]>();
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
const instantiatedChildren = children.flatMap(child => {
|
||||
const childInstance = createInstance(child);
|
||||
if (!childInstance) {
|
||||
return [];
|
||||
}
|
||||
return [child];
|
||||
});
|
||||
if (instantiatedChildren.length > 0) {
|
||||
instantiatedAttachments.set(input, instantiatedChildren);
|
||||
optionsOrPredicateContext?:
|
||||
| {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
child: AppNode;
|
||||
}): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
| Record<string, unknown>,
|
||||
): boolean {
|
||||
const options: {
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: Record<string, unknown>;
|
||||
} =
|
||||
optionsOrPredicateContext &&
|
||||
('stopAtAttachment' in optionsOrPredicateContext ||
|
||||
'skipChild' in optionsOrPredicateContext ||
|
||||
'onMissingApi' in optionsOrPredicateContext ||
|
||||
'predicateContext' in optionsOrPredicateContext)
|
||||
? optionsOrPredicateContext
|
||||
: {
|
||||
predicateContext: optionsOrPredicateContext,
|
||||
};
|
||||
|
||||
(node as Mutable<AppNode>).instance = createAppNodeInstance({
|
||||
extensionFactoryMiddleware,
|
||||
node,
|
||||
return (
|
||||
instantiateAppNodeSubtree({
|
||||
rootNode,
|
||||
apis,
|
||||
attachments: instantiatedAttachments,
|
||||
collector,
|
||||
});
|
||||
|
||||
return node.instance;
|
||||
}
|
||||
|
||||
return createInstance(rootNode) !== undefined;
|
||||
extensionFactoryMiddleware,
|
||||
stopAtAttachment: options.stopAtAttachment,
|
||||
skipChild: options.skipChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
predicateContext: options.predicateContext,
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createExtension,
|
||||
createExtensionDataRef,
|
||||
createFrontendModule,
|
||||
createFrontendPlugin,
|
||||
Extension,
|
||||
@@ -506,4 +508,204 @@ describe('resolveAppNodeSpecs', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry if predicate through to AppNodeSpec', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: ifPredicate,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('value')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0].if).toEqual(ifPredicate);
|
||||
});
|
||||
|
||||
it('should apply plugin if predicates to all plugin extensions', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'one',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('one')],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'two',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('two')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
expect(specs[0].if).toEqual(pluginIf);
|
||||
expect(specs[1].if).toEqual(pluginIf);
|
||||
});
|
||||
|
||||
it('should allow plugin overrides to replace or remove plugin if predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const overrideIf = { permissions: { $contains: 'override.permission' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'one',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('one')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const overriddenSpecs = resolveAppNodeSpecs({
|
||||
features: [plugin.withOverrides({ if: overrideIf })],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
const clearedSpecs = resolveAppNodeSpecs({
|
||||
features: [plugin.withOverrides({ if: undefined })],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(overriddenSpecs).toHaveLength(1);
|
||||
expect(overriddenSpecs[0].if).toEqual(overrideIf);
|
||||
expect(clearedSpecs).toHaveLength(1);
|
||||
expect(clearedSpecs[0].if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should merge plugin and module if predicates with extension predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
|
||||
const moduleIf = { permissions: { $contains: 'module.permission' } };
|
||||
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
|
||||
const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
if: pluginIf,
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'plugin-extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: extensionIf,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('plugin')],
|
||||
}),
|
||||
createExtension({
|
||||
name: 'module-extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('base')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const module = createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
if: moduleIf,
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/module-extension').override({
|
||||
if: moduleExtensionIf,
|
||||
factory: () => [dataRef('module')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const specs = resolveAppNodeSpecs({
|
||||
features: [plugin, module],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
expect(specs[0].id).toBe('test-plugin/plugin-extension');
|
||||
expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] });
|
||||
expect(specs[1].id).toBe('test-plugin/module-extension');
|
||||
expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] });
|
||||
});
|
||||
|
||||
it('should allow module extension overrides to replace or remove extension if predicates', () => {
|
||||
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
|
||||
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
|
||||
const overrideIf = { permissions: { $contains: 'override.permission' } };
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
createExtension({
|
||||
name: 'extension',
|
||||
attachTo: { id: 'app', input: 'root' },
|
||||
if: extensionIf,
|
||||
output: [dataRef],
|
||||
factory: () => [dataRef('base')],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const overriddenSpecs = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/extension').override({
|
||||
if: overrideIf,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
const clearedSpecs = resolveAppNodeSpecs({
|
||||
features: [
|
||||
plugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'test-plugin',
|
||||
extensions: [
|
||||
plugin.getExtension('test-plugin/extension').override({
|
||||
if: undefined,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
builtinExtensions: [],
|
||||
parameters: [],
|
||||
collector,
|
||||
});
|
||||
|
||||
expect(overriddenSpecs).toHaveLength(1);
|
||||
expect(overriddenSpecs[0].if).toEqual(overrideIf);
|
||||
expect(clearedSpecs).toHaveLength(1);
|
||||
expect(clearedSpecs[0].if).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FrontendFeature,
|
||||
FrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { ExtensionParameters } from './readAppExtensionsConfig';
|
||||
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
@@ -40,6 +41,29 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function combinePredicates(
|
||||
left: FilterPredicate | undefined,
|
||||
right: FilterPredicate | undefined,
|
||||
) {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
if (!right) {
|
||||
return left;
|
||||
}
|
||||
|
||||
return { $all: [left, right] };
|
||||
}
|
||||
|
||||
function getExtensionPredicate(options: {
|
||||
internalExtension: ReturnType<typeof toInternalExtension>;
|
||||
}) {
|
||||
if (options.internalExtension.version === 'v2') {
|
||||
return options.internalExtension.if;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function resolveAppNodeSpecs(options: {
|
||||
features?: FrontendFeature[];
|
||||
@@ -79,26 +103,50 @@ export function resolveAppNodeSpecs(options: {
|
||||
};
|
||||
|
||||
const pluginExtensions = plugins.flatMap(plugin => {
|
||||
return OpaqueFrontendPlugin.toInternal(plugin)
|
||||
.extensions.map(extension => ({
|
||||
...extension,
|
||||
plugin,
|
||||
}))
|
||||
const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
|
||||
return internalPlugin.extensions
|
||||
.map(extension => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
return {
|
||||
...internalExtension,
|
||||
plugin,
|
||||
if: combinePredicates(
|
||||
internalPlugin.if,
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(filterForbidden);
|
||||
});
|
||||
const moduleExtensions = modules.flatMap(mod =>
|
||||
toInternalFrontendModule(mod)
|
||||
.extensions.flatMap(extension => {
|
||||
const moduleExtensions = modules.flatMap(mod => {
|
||||
const internalModule = toInternalFrontendModule(mod);
|
||||
return internalModule.extensions
|
||||
.flatMap(extension => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
|
||||
// Modules for plugins that are not installed are ignored
|
||||
const plugin = plugins.find(p => p.pluginId === mod.pluginId);
|
||||
if (!plugin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...extension, plugin }];
|
||||
return [
|
||||
{
|
||||
...internalExtension,
|
||||
plugin,
|
||||
if: combinePredicates(
|
||||
internalModule.if,
|
||||
internalExtension.version === 'v2'
|
||||
? internalExtension.if
|
||||
: undefined,
|
||||
),
|
||||
},
|
||||
];
|
||||
})
|
||||
.filter(filterForbidden),
|
||||
);
|
||||
.filter(filterForbidden);
|
||||
});
|
||||
|
||||
const appPlugin =
|
||||
plugins.find(plugin => plugin.pluginId === 'app') ??
|
||||
@@ -116,6 +164,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -129,6 +178,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
plugin: appPlugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
@@ -148,6 +198,9 @@ export function resolveAppNodeSpecs(options: {
|
||||
configuredExtensions[index].extension = internalExtension;
|
||||
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
|
||||
configuredExtensions[index].params.disabled = internalExtension.disabled;
|
||||
configuredExtensions[index].params.if = getExtensionPredicate({
|
||||
internalExtension,
|
||||
});
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
@@ -157,6 +210,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
source: extension.plugin,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
if: getExtensionPredicate({ internalExtension }),
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
@@ -235,6 +289,7 @@ export function resolveAppNodeSpecs(options: {
|
||||
attachTo: param.params.attachTo,
|
||||
extension: param.extension,
|
||||
disabled: param.params.disabled,
|
||||
if: param.params.if,
|
||||
plugin: param.params.plugin,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
type AnyApiFactory,
|
||||
createApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
|
||||
describe('FrontendApiResolver', () => {
|
||||
it('should cache falsy API values', () => {
|
||||
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
|
||||
const falseFactoryFn = jest.fn(() => false);
|
||||
const registry = new FrontendApiRegistry();
|
||||
|
||||
registry.register({
|
||||
api: falseApiRef,
|
||||
deps: {},
|
||||
factory: falseFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
|
||||
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
|
||||
|
||||
expect(resolver.get(falseApiRef)).toBe(false);
|
||||
expect(resolver.get(falseApiRef)).toBe(false);
|
||||
expect(falseFactoryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should resolve falsy dependencies', () => {
|
||||
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
|
||||
const dependentApiRef = createApiRef<string>({ id: 'test.dependent' });
|
||||
const falseFactoryFn = jest.fn(() => false);
|
||||
const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) =>
|
||||
deps.falseDependency === false ? 'resolved' : 'unexpected',
|
||||
);
|
||||
const registry = new FrontendApiRegistry();
|
||||
|
||||
registry.register({
|
||||
api: falseApiRef,
|
||||
deps: {},
|
||||
factory: falseFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
registry.register({
|
||||
api: dependentApiRef,
|
||||
deps: { falseDependency: falseApiRef },
|
||||
factory: dependentFactoryFn,
|
||||
} as AnyApiFactory);
|
||||
|
||||
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
|
||||
|
||||
expect(resolver.get(dependentApiRef)).toBe('resolved');
|
||||
expect(dependentFactoryFn).toHaveBeenCalledWith({
|
||||
falseDependency: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
AnyApiFactory,
|
||||
AnyApiRef,
|
||||
ApiFactory,
|
||||
ApiHolder,
|
||||
ApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export class FrontendApiRegistry {
|
||||
private readonly factories = new Map<string, AnyApiFactory>();
|
||||
|
||||
register(factory: AnyApiFactory) {
|
||||
if (this.factories.has(factory.api.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.factories.set(factory.api.id, factory);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerAll(factories: AnyApiFactory[]) {
|
||||
for (const factory of factories) {
|
||||
this.register(factory);
|
||||
}
|
||||
}
|
||||
|
||||
set(factory: AnyApiFactory) {
|
||||
this.factories.set(factory.api.id, factory);
|
||||
}
|
||||
|
||||
setAll(factories: Iterable<AnyApiFactory>) {
|
||||
for (const factory of factories) {
|
||||
this.set(factory);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
api: ApiRef<T>,
|
||||
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
|
||||
const factory = this.factories.get(api.id);
|
||||
if (!factory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return factory as ApiFactory<T, T, { [name: string]: unknown }>;
|
||||
}
|
||||
|
||||
getAllApis() {
|
||||
const refs = new Set<AnyApiRef>();
|
||||
for (const factory of this.factories.values()) {
|
||||
refs.add(factory.api);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrontendApiResolver implements ApiHolder {
|
||||
private readonly apis = new Map<string, unknown>();
|
||||
private readonly primaryRegistry?: FrontendApiRegistry;
|
||||
private readonly secondaryRegistry?: FrontendApiRegistry;
|
||||
private readonly fallbackApis?: ApiHolder;
|
||||
|
||||
constructor(options: {
|
||||
primaryRegistry?: FrontendApiRegistry;
|
||||
secondaryRegistry?: FrontendApiRegistry;
|
||||
fallbackApis?: ApiHolder;
|
||||
}) {
|
||||
this.primaryRegistry = options.primaryRegistry;
|
||||
this.secondaryRegistry = options.secondaryRegistry;
|
||||
this.fallbackApis = options.fallbackApis;
|
||||
}
|
||||
|
||||
get<T>(ref: ApiRef<T>): T | undefined {
|
||||
return this.load(ref);
|
||||
}
|
||||
|
||||
isMaterialized(apiRefId: string) {
|
||||
return this.apis.has(apiRefId);
|
||||
}
|
||||
|
||||
invalidate(apiRefIds?: Iterable<string>) {
|
||||
if (apiRefIds === undefined) {
|
||||
this.apis.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const apiRefId of apiRefIds) {
|
||||
this.apis.delete(apiRefId);
|
||||
}
|
||||
}
|
||||
|
||||
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
|
||||
const existing = this.apis.get(ref.id);
|
||||
if (this.apis.has(ref.id)) {
|
||||
return existing as T;
|
||||
}
|
||||
|
||||
const factory =
|
||||
this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
|
||||
if (!factory) {
|
||||
return this.fallbackApis?.get(ref);
|
||||
}
|
||||
|
||||
if (loading.includes(factory.api)) {
|
||||
throw new Error(`Circular dependency of api factory for ${factory.api}`);
|
||||
}
|
||||
|
||||
const deps = {} as { [name: string]: unknown };
|
||||
for (const [key, depRef] of Object.entries(factory.deps)) {
|
||||
const dep = this.load(depRef, [...loading, factory.api]);
|
||||
if (dep === undefined) {
|
||||
throw new Error(
|
||||
`No API factory available for dependency ${depRef} of dependent ${factory.api}`,
|
||||
);
|
||||
}
|
||||
deps[key] = dep;
|
||||
}
|
||||
|
||||
const api = factory.factory(deps);
|
||||
this.apis.set(ref.id, api);
|
||||
return api as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiBlueprint,
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
FrontendFeature,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isInternalFrontendModule,
|
||||
toInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
import { type ExtensionPredicateContext } from './predicates';
|
||||
|
||||
export type ApiFactoryEntry = {
|
||||
node: AppNode;
|
||||
pluginId: string;
|
||||
factory: AnyApiFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers feature flag declarations on an already prepared API holder.
|
||||
*
|
||||
* This is primarily used when bootstrap reuses APIs from a provided session
|
||||
* state rather than building a fresh registry from bootstrap-visible factories.
|
||||
*/
|
||||
export function registerFeatureFlagDeclarationsInHolder(
|
||||
apis: ApiHolder,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates the feature flags API factory so plugin and module declarations are
|
||||
* registered whenever that API is instantiated.
|
||||
*/
|
||||
export function wrapFeatureFlagApiFactory(
|
||||
factory: AnyApiFactory,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
if (factory.api.id !== featureFlagsApiRef.id) {
|
||||
return factory;
|
||||
}
|
||||
|
||||
return {
|
||||
...factory,
|
||||
factory(deps) {
|
||||
const featureFlagApi = factory.factory(
|
||||
deps,
|
||||
) as typeof featureFlagsApiRef.T;
|
||||
registerFeatureFlagDeclarations(featureFlagApi, features);
|
||||
return featureFlagApi;
|
||||
},
|
||||
} as AnyApiFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles deferred API factories into the finalized API registry.
|
||||
*
|
||||
* It preserves bootstrap-frozen APIs, allows safe deferred additions, and
|
||||
* reports cases where bootstrap-visible extensions relied on APIs that only
|
||||
* became available during finalization.
|
||||
*/
|
||||
export function syncFinalApiFactories(options: {
|
||||
deferredApiNodes: Iterable<AppNode>;
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
apiResolver: FrontendApiResolver;
|
||||
collector: ErrorCollector;
|
||||
features: FrontendFeature[];
|
||||
bootstrapApiFactoryEntries: ReadonlyMap<string, ApiFactoryEntry>;
|
||||
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
|
||||
predicateContext: ExtensionPredicateContext;
|
||||
}) {
|
||||
const finalApiEntries = collectApiFactoryEntries({
|
||||
apiNodes: options.deferredApiNodes,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
entries: new Map(options.bootstrapApiFactoryEntries),
|
||||
});
|
||||
// Only newly introduced or still-safe overrides are registered here. Any
|
||||
// bootstrap-materialized API remains frozen for the lifetime of the app.
|
||||
const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
|
||||
const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
|
||||
entry.factory.api.id,
|
||||
);
|
||||
if (!bootstrapEntry) {
|
||||
return true;
|
||||
}
|
||||
if (bootstrapEntry.factory === entry.factory) {
|
||||
return false;
|
||||
}
|
||||
if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
|
||||
message:
|
||||
`Extension '${entry.node.spec.id}' tried to override API ` +
|
||||
`'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
|
||||
'The bootstrap implementation was kept and the deferred override was ignored.',
|
||||
context: {
|
||||
node: entry.node,
|
||||
apiRefId: entry.factory.api.id,
|
||||
bootstrapNode: bootstrapEntry.node,
|
||||
pluginId: entry.pluginId,
|
||||
bootstrapPluginId: bootstrapEntry.pluginId,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const changedFactories = changedEntries.map(entry =>
|
||||
wrapFeatureFlagApiFactory(entry.factory, options.features),
|
||||
);
|
||||
options.appApiRegistry.setAll(changedFactories);
|
||||
options.apiResolver.invalidate(
|
||||
changedFactories.map(factory => factory.api.id),
|
||||
);
|
||||
for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
|
||||
if (
|
||||
options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
|
||||
!finalApiEntries.has(bootstrapAccess.apiRefId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
message:
|
||||
`Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
|
||||
`'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
|
||||
'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
|
||||
context: {
|
||||
node: bootstrapAccess.node,
|
||||
apiRefId: bootstrapAccess.apiRefId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_API_HOLDER: ApiHolder = {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
function registerFeatureFlagDeclarations(
|
||||
featureFlagApi: typeof featureFlagsApiRef.T,
|
||||
features: FrontendFeature[],
|
||||
) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiates API extension subtrees in isolation and extracts the factories
|
||||
* they provide without mutating the live app tree.
|
||||
*
|
||||
* The collected entries are later used both for bootstrap registration and for
|
||||
* the finalization-time reconciliation of deferred API roots.
|
||||
*/
|
||||
export function collectApiFactoryEntries(options: {
|
||||
apiNodes: Iterable<AppNode>;
|
||||
collector: ErrorCollector;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
entries?: Map<string, ApiFactoryEntry>;
|
||||
}): Map<string, ApiFactoryEntry> {
|
||||
const factoriesById = options.entries ?? new Map<string, ApiFactoryEntry>();
|
||||
for (const apiNode of options.apiNodes) {
|
||||
// API extensions are instantiated in isolation so we can inspect the
|
||||
// produced factories without mutating the live app tree.
|
||||
const detachedApiNode = instantiateAppNodeSubtree({
|
||||
rootNode: apiNode,
|
||||
apis: EMPTY_API_HOLDER,
|
||||
collector: options.collector,
|
||||
predicateContext: options.predicateContext,
|
||||
writeNodeInstances: false,
|
||||
reuseExistingInstances: false,
|
||||
});
|
||||
if (!detachedApiNode) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = detachedApiNode.instance?.getData(
|
||||
ApiBlueprint.dataRefs.factory,
|
||||
);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiRefId);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is attempted to be inferred from the API
|
||||
// reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
node: apiNode,
|
||||
factory: apiFactory,
|
||||
});
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return factoriesById;
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRefId: string): string {
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'core') {
|
||||
return 'app';
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -82,6 +82,21 @@ export type AppErrorTypes = {
|
||||
existingPluginId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
|
||||
context: { node: AppNode };
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
|
||||
context: { node: AppNode; apiRefId: string };
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
bootstrapNode: AppNode;
|
||||
pluginId: string;
|
||||
bootstrapPluginId: string;
|
||||
};
|
||||
};
|
||||
// routing
|
||||
ROUTE_DUPLICATE: {
|
||||
context: { routeId: string };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,209 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiBlueprint,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
AppTree,
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
AnyRouteRefParams,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
ExternalRouteRef,
|
||||
featureFlagsApiRef,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
identityApiRef,
|
||||
RouteFunc,
|
||||
RouteRef,
|
||||
RouteResolutionApi,
|
||||
routeResolutionApiRef,
|
||||
SubRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from './types';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueApiRef,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
resolveExtensionDefinition,
|
||||
toInternalExtension,
|
||||
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
import {
|
||||
extractRouteInfoFromAppNode,
|
||||
RouteInfo,
|
||||
} from '../routing/extractRouteInfoFromAppNode';
|
||||
|
||||
import { CreateAppRouteBinder } from '../routing';
|
||||
import { RouteResolver } from '../routing/RouteResolver';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { FrontendPluginInfoResolver } from './createPluginInfoAttacher';
|
||||
import {
|
||||
toInternalFrontendModule,
|
||||
isInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
|
||||
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
|
||||
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
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 { matchRoutes } from 'react-router-dom';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
createSessionStateFromApis,
|
||||
CreateSpecializedAppInternalOptions,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
} from './prepareSpecializedApp';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
): FrontendFeature[] {
|
||||
// Start by removing duplicates by reference
|
||||
const features = Array.from(new Set(allFeatures));
|
||||
|
||||
// Plugins are deduplicated by ID, last one wins
|
||||
const seenIds = new Set<string>();
|
||||
return features
|
||||
.reverse()
|
||||
.filter(feature => {
|
||||
if (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return true;
|
||||
}
|
||||
if (seenIds.has(feature.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(feature.id);
|
||||
return true;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class AppTreeApiProxy implements AppTreeApi {
|
||||
#routeInfo?: RouteInfo;
|
||||
private readonly tree: AppTree;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(tree: AppTree, appBasePath: string) {
|
||||
this.tree = tree;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
private checkIfInitialized() {
|
||||
if (!this.#routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTree() {
|
||||
this.checkIfInitialized();
|
||||
|
||||
return { tree: this.tree };
|
||||
}
|
||||
|
||||
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
|
||||
this.checkIfInitialized();
|
||||
|
||||
let path = routePath;
|
||||
if (path.startsWith(this.appBasePath)) {
|
||||
path = path.slice(this.appBasePath.length);
|
||||
}
|
||||
|
||||
const matchedRoutes = matchRoutes(this.#routeInfo!.routeObjects, path);
|
||||
|
||||
const matchedAppNodes =
|
||||
matchedRoutes
|
||||
?.filter(routeObj => !!routeObj.route.appNode)
|
||||
.map(routeObj => routeObj.route.appNode!) || [];
|
||||
|
||||
return { nodes: matchedAppNodes };
|
||||
}
|
||||
|
||||
initialize(routeInfo: RouteInfo) {
|
||||
this.#routeInfo = routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
#delegate: RouteResolutionApi | undefined;
|
||||
#routeObjects: BackstageRouteObject[] | undefined;
|
||||
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
|
||||
appBasePath: string,
|
||||
) {
|
||||
this.routeBindings = routeBindings;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: { sourcePath?: string },
|
||||
): RouteFunc<TParams> | undefined {
|
||||
if (!this.#delegate) {
|
||||
throw new Error(
|
||||
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.#delegate.resolve(anyRouteRef, options);
|
||||
}
|
||||
|
||||
initialize(
|
||||
routeInfo: RouteInfo,
|
||||
routeRefsById: Map<string, RouteRef | SubRouteRef>,
|
||||
) {
|
||||
this.#delegate = new RouteResolver(
|
||||
routeInfo.routePaths,
|
||||
routeInfo.routeParents,
|
||||
routeInfo.routeObjects,
|
||||
this.routeBindings,
|
||||
this.appBasePath,
|
||||
routeInfo.routeAliasResolver,
|
||||
routeRefsById,
|
||||
);
|
||||
this.#routeObjects = routeInfo.routeObjects;
|
||||
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
getRouteObjects() {
|
||||
return this.#routeObjects;
|
||||
}
|
||||
}
|
||||
export type { CreateSpecializedAppInternalOptions };
|
||||
|
||||
/**
|
||||
* Options for {@link createSpecializedApp}.
|
||||
*
|
||||
* @deprecated Use `PrepareSpecializedAppOptions` with `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CreateSpecializedAppOptions = {
|
||||
@@ -245,12 +64,7 @@ export type CreateSpecializedAppOptions = {
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* A replacement API holder implementation to use.
|
||||
*
|
||||
* By default, a new API holder will be constructed automatically based on
|
||||
* the other inputs. If you pass in a custom one here, none of that
|
||||
* automation will take place - so you will have to take care to supply all
|
||||
* those APIs yourself.
|
||||
* APIs to expose to the app during startup.
|
||||
*/
|
||||
apis?: ApiHolder;
|
||||
|
||||
@@ -272,257 +86,29 @@ export type CreateSpecializedAppOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// Internal options type, not exported in the public API
|
||||
export interface CreateSpecializedAppInternalOptions
|
||||
extends CreateSpecializedAppOptions {
|
||||
__internal?: {
|
||||
apiFactoryOverrides?: AnyApiFactory[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty app without any default features. This is a low-level API is
|
||||
* intended for use in tests or specialized setups. Typically you want to use
|
||||
* `createApp` from `@backstage/frontend-defaults` instead.
|
||||
*
|
||||
* @deprecated Use `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
} {
|
||||
const internalOptions = options as CreateSpecializedAppInternalOptions;
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp {
|
||||
const sessionState = options?.advanced?.apis
|
||||
? createSessionStateFromApis(options.advanced.apis)
|
||||
: undefined;
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
features,
|
||||
builtinExtensions: [
|
||||
resolveExtensionDefinition(Root, { namespace: 'root' }),
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const factories = createApiFactories({ tree, collector });
|
||||
const appBasePath = getBasePath(config);
|
||||
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
|
||||
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
|
||||
appBasePath,
|
||||
);
|
||||
|
||||
const appIdentityProxy = new AppIdentityProxy();
|
||||
const apis =
|
||||
options?.advanced?.apis ??
|
||||
createApiHolder({
|
||||
factories,
|
||||
staticFactories: [
|
||||
createApiFactory(appTreeApiRef, appTreeApi),
|
||||
createApiFactory(configApiRef, config),
|
||||
createApiFactory(routeResolutionApiRef, routeResolutionApi),
|
||||
createApiFactory(identityApiRef, appIdentityProxy),
|
||||
...(internalOptions?.__internal?.apiFactoryOverrides ?? []),
|
||||
],
|
||||
});
|
||||
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now instantiate the entire tree, which will skip anything that's already been instantiated
|
||||
instantiateAppNodeTree(
|
||||
tree.root,
|
||||
apis,
|
||||
collector,
|
||||
mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
),
|
||||
);
|
||||
|
||||
const routeInfo = extractRouteInfoFromAppNode(
|
||||
tree.root,
|
||||
createRouteAliasResolver(routeRefsById),
|
||||
);
|
||||
|
||||
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
|
||||
appTreeApi.initialize(routeInfo);
|
||||
|
||||
return { apis, tree, errors: collector.collectErrors() };
|
||||
}
|
||||
|
||||
function createApiFactories(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): AnyApiFactory[] {
|
||||
const emptyApiHolder = ApiRegistry.from([]);
|
||||
const factoriesById = new Map<
|
||||
string,
|
||||
{ pluginId: string; factory: AnyApiFactory }
|
||||
>();
|
||||
|
||||
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
|
||||
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiFactory.api);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is inferred from the explicit pluginId or
|
||||
// legacy plugin-prefixed API reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, { pluginId, factory: apiFactory });
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(factoriesById.values(), entry => entry.factory);
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRef: { id: string }): string {
|
||||
if (OpaqueApiRef.isType(apiRef)) {
|
||||
const { pluginId } = OpaqueApiRef.toInternal(apiRef);
|
||||
if (pluginId) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
|
||||
const apiRefId = apiRef.id;
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function createApiHolder(options: {
|
||||
factories: AnyApiFactory[];
|
||||
staticFactories: AnyApiFactory[];
|
||||
}): ApiHolder {
|
||||
const factoryRegistry = new ApiFactoryRegistry();
|
||||
|
||||
for (const factory of options.factories.slice().reverse()) {
|
||||
factoryRegistry.register('default', factory);
|
||||
}
|
||||
|
||||
for (const factory of options.staticFactories) {
|
||||
factoryRegistry.register('static', factory);
|
||||
}
|
||||
|
||||
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
|
||||
|
||||
return new ApiResolver(factoryRegistry);
|
||||
}
|
||||
|
||||
function mergeExtensionFactoryMiddleware(
|
||||
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
|
||||
): ExtensionFactoryMiddleware | undefined {
|
||||
if (!middlewares) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(middlewares)) {
|
||||
return middlewares;
|
||||
}
|
||||
if (middlewares.length <= 1) {
|
||||
return middlewares[0];
|
||||
}
|
||||
return middlewares.reduce((prev, next) => {
|
||||
if (!prev || !next) {
|
||||
return prev ?? next;
|
||||
}
|
||||
return (orig, ctx) => {
|
||||
const internalExt = toInternalExtension(ctx.node.spec.extension);
|
||||
if (internalExt.version !== 'v2') {
|
||||
return orig();
|
||||
}
|
||||
return next(ctxOverrides => {
|
||||
return createExtensionDataContainer(
|
||||
prev(orig, {
|
||||
node: ctx.node,
|
||||
apis: ctx.apis,
|
||||
config: ctxOverrides?.config ?? ctx.config,
|
||||
}),
|
||||
'extension factory middleware',
|
||||
);
|
||||
}, ctx);
|
||||
};
|
||||
});
|
||||
return prepareSpecializedApp({
|
||||
features: options?.features,
|
||||
config: options?.config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: {
|
||||
...options?.advanced,
|
||||
sessionState,
|
||||
},
|
||||
}).finalize();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
type BootstrapSpecializedApp,
|
||||
type FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
type PrepareSpecializedAppOptions,
|
||||
type PreparedSpecializedApp,
|
||||
type SpecializedAppSessionState,
|
||||
} from './prepareSpecializedApp';
|
||||
export {
|
||||
createSpecializedApp,
|
||||
type CreateSpecializedAppOptions,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* 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 {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppTree,
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
ExternalRouteRef,
|
||||
identityApiRef,
|
||||
RouteFunc,
|
||||
RouteRef,
|
||||
RouteResolutionApi,
|
||||
routeResolutionApiRef,
|
||||
SubRouteRef,
|
||||
type AnyRouteRefParams,
|
||||
type AppNode,
|
||||
type ExtensionFactoryMiddleware,
|
||||
type IdentityApi,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { matchRoutes } from 'react-router-dom';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import { RouteResolver } from '../routing/RouteResolver';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import {
|
||||
extractRouteInfoFromAppNode,
|
||||
type RouteInfo,
|
||||
} from '../routing/extractRouteInfoFromAppNode';
|
||||
import { type BackstageRouteObject } from '../routing/types';
|
||||
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
|
||||
import {
|
||||
FrontendApiRegistry,
|
||||
FrontendApiResolver,
|
||||
} from './FrontendApiRegistry';
|
||||
import { type ExtensionPredicateContext } from './predicates';
|
||||
import { type ErrorCollector } from './createErrorCollector';
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
export class AppTreeApiProxy implements AppTreeApi {
|
||||
#routeInfo?: RouteInfo;
|
||||
private readonly tree: AppTree;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(tree: AppTree, appBasePath: string) {
|
||||
this.tree = tree;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
private checkIfInitialized() {
|
||||
if (!this.#routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTree() {
|
||||
this.checkIfInitialized();
|
||||
|
||||
return { tree: this.tree };
|
||||
}
|
||||
|
||||
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
|
||||
this.checkIfInitialized();
|
||||
const routeInfo = this.#routeInfo;
|
||||
if (!routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
let path = routePath;
|
||||
if (path.startsWith(this.appBasePath)) {
|
||||
path = path.slice(this.appBasePath.length);
|
||||
}
|
||||
|
||||
const matchedRoutes = matchRoutes(routeInfo.routeObjects, path);
|
||||
|
||||
const matchedAppNodes =
|
||||
matchedRoutes?.flatMap(routeObj => {
|
||||
const appNode = routeObj.route.appNode;
|
||||
return appNode ? [appNode] : [];
|
||||
}) || [];
|
||||
|
||||
return { nodes: matchedAppNodes };
|
||||
}
|
||||
|
||||
initialize(routeInfo: RouteInfo) {
|
||||
this.#routeInfo = routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
export class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
#delegate: RouteResolutionApi | undefined;
|
||||
#routeObjects: BackstageRouteObject[] | undefined;
|
||||
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
|
||||
appBasePath: string,
|
||||
) {
|
||||
this.routeBindings = routeBindings;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: { sourcePath?: string },
|
||||
): RouteFunc<TParams> | undefined {
|
||||
if (!this.#delegate) {
|
||||
throw new Error(
|
||||
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.#delegate.resolve(anyRouteRef, options);
|
||||
}
|
||||
|
||||
initialize(
|
||||
routeInfo: RouteInfo,
|
||||
routeRefsById: Map<string, RouteRef | SubRouteRef>,
|
||||
) {
|
||||
this.#delegate = new RouteResolver(
|
||||
routeInfo.routePaths,
|
||||
routeInfo.routeParents,
|
||||
routeInfo.routeObjects,
|
||||
this.routeBindings,
|
||||
this.appBasePath,
|
||||
routeInfo.routeAliasResolver,
|
||||
routeRefsById,
|
||||
);
|
||||
this.#routeObjects = routeInfo.routeObjects;
|
||||
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
getRouteObjects() {
|
||||
return this.#routeObjects;
|
||||
}
|
||||
}
|
||||
|
||||
export class PreparedAppIdentityProxy extends AppIdentityProxy {
|
||||
#onTargetSet?:
|
||||
| ((identityApi: Parameters<AppIdentityProxy['setTarget']>[0]) => void)
|
||||
| undefined;
|
||||
|
||||
setTargetHandlers(options: {
|
||||
onTargetSet?(
|
||||
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
|
||||
): void;
|
||||
}) {
|
||||
this.#onTargetSet = options.onTargetSet;
|
||||
}
|
||||
|
||||
clearTargetHandlers() {
|
||||
this.#onTargetSet = undefined;
|
||||
}
|
||||
|
||||
override setTarget(
|
||||
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
|
||||
targetOptions: Parameters<AppIdentityProxy['setTarget']>[1],
|
||||
) {
|
||||
super.setTarget(identityApi, targetOptions);
|
||||
|
||||
const onTargetSet = this.#onTargetSet;
|
||||
if (!onTargetSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearTargetHandlers();
|
||||
onTargetSet(identityApi);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPhaseApis(options: {
|
||||
tree: AppTree;
|
||||
config: ConfigApi;
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
fallbackApis?: ApiHolder;
|
||||
includeConfigApi: boolean;
|
||||
appBasePath: string;
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
staticFactories: AnyApiFactory[];
|
||||
}) {
|
||||
const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
options.routeBindings,
|
||||
options.appBasePath,
|
||||
);
|
||||
const identityProxy = new PreparedAppIdentityProxy();
|
||||
const phaseApiRegistry = new FrontendApiRegistry();
|
||||
phaseApiRegistry.registerAll([
|
||||
createApiFactory(appTreeApiRef, appTreeApi),
|
||||
...(options.includeConfigApi
|
||||
? [createApiFactory(configApiRef, options.config)]
|
||||
: []),
|
||||
createApiFactory(routeResolutionApiRef, routeResolutionApi),
|
||||
createApiFactory(identityApiRef, identityProxy),
|
||||
...options.staticFactories,
|
||||
]);
|
||||
|
||||
const apis = new FrontendApiResolver({
|
||||
primaryRegistry: phaseApiRegistry,
|
||||
secondaryRegistry: options.appApiRegistry,
|
||||
fallbackApis: options.fallbackApis,
|
||||
});
|
||||
|
||||
return {
|
||||
apis,
|
||||
routeResolutionApi,
|
||||
appTreeApi,
|
||||
identityApiProxy: identityProxy,
|
||||
};
|
||||
}
|
||||
|
||||
export function instantiateAndInitializePhaseTree(options: {
|
||||
tree: AppTree;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
predicateContext?: ExtensionPredicateContext;
|
||||
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
|
||||
}) {
|
||||
instantiateAppNodeTree(
|
||||
options.tree.root,
|
||||
options.apis,
|
||||
options.collector,
|
||||
options.extensionFactoryMiddleware,
|
||||
{
|
||||
...(options.stopAtAttachment
|
||||
? { stopAtAttachment: options.stopAtAttachment }
|
||||
: {}),
|
||||
skipChild: options.skipChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
predicateContext: options.predicateContext,
|
||||
},
|
||||
);
|
||||
|
||||
const routeInfo = extractRouteInfoFromAppNode(
|
||||
options.tree.root,
|
||||
createRouteAliasResolver(options.routeRefsById),
|
||||
);
|
||||
|
||||
options.routeResolutionApi.initialize(
|
||||
routeInfo,
|
||||
options.routeRefsById.routes,
|
||||
);
|
||||
options.appTreeApi.initialize(routeInfo);
|
||||
}
|
||||
|
||||
export function setIdentityApiTarget(options: {
|
||||
identityApiProxy: AppIdentityProxy;
|
||||
identityApi: IdentityApi;
|
||||
signOutTargetUrl: string;
|
||||
}) {
|
||||
options.identityApiProxy.setTarget(options.identityApi, {
|
||||
signOutTargetUrl: options.signOutTargetUrl,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiHolder,
|
||||
createApiRef,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type {
|
||||
EvaluatePermissionRequest,
|
||||
EvaluatePermissionResponse,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
export type ExtensionPredicateContext = {
|
||||
featureFlags: string[];
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
export const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = {
|
||||
featureFlags: [],
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react
|
||||
type MinimalPermissionApi = {
|
||||
authorize(
|
||||
request: EvaluatePermissionRequest,
|
||||
): Promise<EvaluatePermissionResponse>;
|
||||
};
|
||||
|
||||
export const localPermissionApiRef = createApiRef<MinimalPermissionApi>({
|
||||
id: 'plugin.permission.api',
|
||||
});
|
||||
|
||||
export function createPredicateContextLoader(options: {
|
||||
apis: ApiHolder;
|
||||
predicateReferences: ExtensionPredicateContext;
|
||||
}) {
|
||||
function getActiveFeatureFlags() {
|
||||
const featureFlagsApi = options.apis.get(featureFlagsApiRef);
|
||||
if (!featureFlagsApi) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return options.predicateReferences.featureFlags.filter(name =>
|
||||
featureFlagsApi.isActive(name),
|
||||
);
|
||||
}
|
||||
|
||||
function getImmediate(): ExtensionPredicateContext | undefined {
|
||||
if (options.predicateReferences.permissions.length > 0) {
|
||||
const permissionApi = options.apis.get(localPermissionApiRef);
|
||||
if (permissionApi) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: getActiveFeatureFlags(),
|
||||
permissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const immediatePredicateContext = getImmediate();
|
||||
if (immediatePredicateContext) {
|
||||
return immediatePredicateContext;
|
||||
}
|
||||
|
||||
let allowedPermissions: string[] = [];
|
||||
const permissionApi = options.apis.get(localPermissionApiRef);
|
||||
if (permissionApi) {
|
||||
const permissionNames = options.predicateReferences.permissions;
|
||||
const responses = await Promise.all(
|
||||
permissionNames.map(name =>
|
||||
permissionApi.authorize({
|
||||
permission: { name, type: 'basic', attributes: {} },
|
||||
}),
|
||||
),
|
||||
);
|
||||
allowedPermissions = permissionNames.filter(
|
||||
(_, i) => responses[i].result === 'ALLOW',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: getActiveFeatureFlags(),
|
||||
permissions: allowedPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getImmediate,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPredicateReferences(
|
||||
nodes: Iterable<{ spec: { if?: FilterPredicate } }>,
|
||||
): ExtensionPredicateContext {
|
||||
const featureFlags = new Set<string>();
|
||||
const permissions = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.spec.if === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const name of extractFeatureFlagNames(node.spec.if)) {
|
||||
featureFlags.add(name);
|
||||
}
|
||||
for (const name of extractPermissionNames(node.spec.if)) {
|
||||
permissions.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
featureFlags: Array.from(featureFlags),
|
||||
permissions: Array.from(permissions),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a FilterPredicate and returns all string values referenced
|
||||
* by `featureFlags: { $contains: '...' }` expressions. This lets us call
|
||||
* `isActive()` only for the flags that are actually used in predicates rather
|
||||
* than fetching the full registered-flag list.
|
||||
*/
|
||||
function extractFeatureFlagNames(predicate: FilterPredicate): string[] {
|
||||
return extractPredicateKeyNames(predicate, 'featureFlags');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks a FilterPredicate and returns all string values referenced
|
||||
* by `permissions: { $contains: '...' }` expressions. This lets us issue a
|
||||
* single batched authorize call for only the permissions actually referenced.
|
||||
*/
|
||||
function extractPermissionNames(predicate: FilterPredicate): string[] {
|
||||
return extractPredicateKeyNames(predicate, 'permissions');
|
||||
}
|
||||
|
||||
function extractPredicateKeyNames(
|
||||
predicate: FilterPredicate,
|
||||
key: string,
|
||||
): string[] {
|
||||
if (typeof predicate !== 'object' || predicate === null) {
|
||||
return [];
|
||||
}
|
||||
const obj = predicate as Record<string, unknown>;
|
||||
if (Array.isArray(obj.$all)) {
|
||||
return (obj.$all as FilterPredicate[]).flatMap(p =>
|
||||
extractPredicateKeyNames(p, key),
|
||||
);
|
||||
}
|
||||
if (Array.isArray(obj.$any)) {
|
||||
return (obj.$any as FilterPredicate[]).flatMap(p =>
|
||||
extractPredicateKeyNames(p, key),
|
||||
);
|
||||
}
|
||||
if (obj.$not !== undefined) {
|
||||
return extractPredicateKeyNames(obj.$not as FilterPredicate, key);
|
||||
}
|
||||
const value = obj[key];
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
const contains = (value as Record<string, unknown>).$contains;
|
||||
if (typeof contains === 'string') {
|
||||
return [contains];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { isError } from '@backstage/errors';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiHolder,
|
||||
AppTree,
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
AppNode,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
IdentityApi,
|
||||
identityApiRef,
|
||||
createExtensionDataRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { ComponentType, ReactNode } from 'react';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
resolveExtensionDefinition,
|
||||
toInternalExtension,
|
||||
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
import { CreateAppRouteBinder } from '../routing';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
|
||||
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
import {
|
||||
createPhaseApis,
|
||||
instantiateAndInitializePhaseTree,
|
||||
setIdentityApiTarget,
|
||||
} from './phaseApis';
|
||||
import {
|
||||
collectPredicateReferences,
|
||||
createPredicateContextLoader,
|
||||
EMPTY_PREDICATE_CONTEXT,
|
||||
type ExtensionPredicateContext,
|
||||
} from './predicates';
|
||||
import { FrontendApiRegistry } from './FrontendApiRegistry';
|
||||
import {
|
||||
ApiFactoryEntry,
|
||||
collectApiFactoryEntries,
|
||||
registerFeatureFlagDeclarationsInHolder,
|
||||
syncFinalApiFactories,
|
||||
wrapFeatureFlagApiFactory,
|
||||
} from './apiFactories';
|
||||
import {
|
||||
attachThrowingFinalizationChild,
|
||||
BootstrapClassification,
|
||||
classifyBootstrapTree,
|
||||
clearFinalizationBoundaryInstances,
|
||||
createBootstrapApp,
|
||||
prepareFinalizedTree,
|
||||
} from './treeLifecycle';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
): FrontendFeature[] {
|
||||
// Start by removing duplicates by reference
|
||||
const features = Array.from(new Set(allFeatures));
|
||||
|
||||
// Plugins are deduplicated by ID, last one wins
|
||||
const seenIds = new Set<string>();
|
||||
return features
|
||||
.reverse()
|
||||
.filter(feature => {
|
||||
if (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return true;
|
||||
}
|
||||
if (seenIds.has(feature.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(feature.id);
|
||||
return true;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
type SignInPageProps = {
|
||||
onSignInSuccess(identityApi: IdentityApi): void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of bootstrapping a prepared specialized app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type BootstrapSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
tree: AppTree;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of finalizing a prepared specialized app.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FinalizedSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
sessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
type SignInRuntime = {
|
||||
readyIdentityApi?: IdentityApi;
|
||||
requiresSignIn: boolean;
|
||||
};
|
||||
|
||||
type FinalizationState = {
|
||||
started: boolean;
|
||||
promise: Promise<FinalizedSpecializedApp>;
|
||||
resolve(app: FinalizedSpecializedApp): void;
|
||||
reject(error: unknown): void;
|
||||
};
|
||||
|
||||
type FinalizationMode = 'onFinalized' | 'finalize';
|
||||
|
||||
type InternalSpecializedAppSessionState = {
|
||||
apis: ApiHolder;
|
||||
identityApi?: IdentityApi;
|
||||
predicateContext: ExtensionPredicateContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opaque reusable session state for specialized apps.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SpecializedAppSessionState = {
|
||||
$$type: '@backstage/SpecializedAppSessionState';
|
||||
};
|
||||
|
||||
const OpaqueSpecializedAppSessionState = OpaqueType.create<{
|
||||
public: SpecializedAppSessionState;
|
||||
versions: InternalSpecializedAppSessionState & {
|
||||
version: 'v1';
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/SpecializedAppSessionState',
|
||||
versions: ['v1'],
|
||||
});
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<SignInPageProps>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
/**
|
||||
* Options for {@link prepareSpecializedApp}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PrepareSpecializedAppOptions = {
|
||||
/**
|
||||
* The list of features to load.
|
||||
*/
|
||||
features?: FrontendFeature[];
|
||||
|
||||
/**
|
||||
* The config API implementation to use. For most normal apps, this should be
|
||||
* specified.
|
||||
*
|
||||
* If none is given, a new _empty_ config will be used during startup. In
|
||||
* later stages of the app lifecycle, the config API in the API holder will be
|
||||
* used.
|
||||
*/
|
||||
config?: ConfigApi;
|
||||
|
||||
/**
|
||||
* Allows for the binding of plugins' external route refs within the app.
|
||||
*/
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
|
||||
/**
|
||||
* Advanced, more rarely used options.
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* A reusable specialized app session state to use.
|
||||
*
|
||||
* This can be obtained from either the app passed to
|
||||
* {@link PreparedSpecializedApp.onFinalized} or from
|
||||
* {@link PreparedSpecializedApp.finalize}, and reused in a future app
|
||||
* instance to skip sign-in and session preparation.
|
||||
*/
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
|
||||
/**
|
||||
* Applies one or more middleware on every extension, as they are added to
|
||||
* the application.
|
||||
*
|
||||
* This is an advanced use case for modifying extension data on the fly as
|
||||
* it gets emitted by extensions being instantiated.
|
||||
*/
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
|
||||
/**
|
||||
* Allows for customizing how plugin info is retrieved.
|
||||
*/
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of {@link prepareSpecializedApp}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(): FinalizedSpecializedApp;
|
||||
};
|
||||
|
||||
// Internal options type, not exported in the public API
|
||||
export interface CreateSpecializedAppInternalOptions
|
||||
extends PrepareSpecializedAppOptions {
|
||||
__internal?: {
|
||||
apiFactoryOverrides?: AnyApiFactory[];
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionStateFromApis(
|
||||
apis: ApiHolder,
|
||||
): SpecializedAppSessionState {
|
||||
return OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis,
|
||||
identityApi: apis.get(identityApiRef),
|
||||
predicateContext: EMPTY_PREDICATE_CONTEXT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an app without instantiating the full extension tree.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This is useful for split sign-in flows where the sign-in page should be
|
||||
* rendered first, and the full app finalized once an identity has been
|
||||
* captured.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function prepareSpecializedApp(
|
||||
options?: PrepareSpecializedAppOptions,
|
||||
): PreparedSpecializedApp {
|
||||
const internalOptions = options as CreateSpecializedAppInternalOptions;
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
features,
|
||||
builtinExtensions: [
|
||||
resolveExtensionDefinition(Root, { namespace: 'root' }),
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const appBasePath = getBasePath(config);
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeBindings = resolveRouteBindings(
|
||||
options?.bindRoutes,
|
||||
config,
|
||||
routeRefsById,
|
||||
collector,
|
||||
);
|
||||
|
||||
const mergedExtensionFactoryMiddleware = mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
);
|
||||
const providedSessionState = options?.advanced?.sessionState;
|
||||
const providedSessionData = providedSessionState
|
||||
? OpaqueSpecializedAppSessionState.toInternal(providedSessionState)
|
||||
: undefined;
|
||||
const providedApis = providedSessionData?.apis;
|
||||
// Bootstrap only renders the parts of the tree that are known to be safe
|
||||
// before predicate context and sign-in have been resolved.
|
||||
const bootstrapClassification = classifyBootstrapTree({
|
||||
tree,
|
||||
collector,
|
||||
});
|
||||
const predicateReferences = collectPredicateReferences(tree.nodes.values());
|
||||
const appApiRegistry = new FrontendApiRegistry();
|
||||
const internalStaticFactories =
|
||||
internalOptions?.__internal?.apiFactoryOverrides ?? [];
|
||||
const phaseStaticFactories = [...internalStaticFactories];
|
||||
const bootstrapApiFactoryEntries = new Map<string, ApiFactoryEntry>();
|
||||
const bootstrapMissingApiAccesses = new Map<
|
||||
string,
|
||||
{ node: AppNode; apiRefId: string }
|
||||
>();
|
||||
|
||||
if (providedApis) {
|
||||
// Reused session state already carries a fully prepared API holder, so the
|
||||
// bootstrap path only needs to register feature flag declarations on top.
|
||||
registerFeatureFlagDeclarationsInHolder(providedApis, features);
|
||||
} else {
|
||||
// Bootstrap materializes only the immediately visible API factories. Any
|
||||
// predicate-gated API roots are revisited during finalization.
|
||||
collectApiFactoryEntries({
|
||||
apiNodes: (tree.root.edges.attachments.get('apis') ?? []).filter(
|
||||
apiNode => !bootstrapClassification.deferredApiRoots.has(apiNode),
|
||||
),
|
||||
collector,
|
||||
entries: bootstrapApiFactoryEntries,
|
||||
});
|
||||
const apiFactories = Array.from(
|
||||
bootstrapApiFactoryEntries.values(),
|
||||
entry => wrapFeatureFlagApiFactory(entry.factory, features),
|
||||
);
|
||||
appApiRegistry.registerAll(apiFactories);
|
||||
}
|
||||
const phase = createPhaseApis({
|
||||
tree,
|
||||
config,
|
||||
appApiRegistry,
|
||||
fallbackApis: providedApis,
|
||||
includeConfigApi: !providedApis,
|
||||
appBasePath,
|
||||
routeBindings,
|
||||
staticFactories: phaseStaticFactories,
|
||||
});
|
||||
const predicateContextLoader = createPredicateContextLoader({
|
||||
apis: phase.apis,
|
||||
predicateReferences,
|
||||
});
|
||||
let signInRuntime: SignInRuntime | undefined;
|
||||
let finalized: FinalizedSpecializedApp | undefined;
|
||||
let bootstrapApp: BootstrapSpecializedApp | undefined;
|
||||
|
||||
function updateIdentityApiTarget(identityApi?: IdentityApi) {
|
||||
if (!identityApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIdentityApiTarget({
|
||||
identityApiProxy: phase.identityApiProxy,
|
||||
identityApi,
|
||||
signOutTargetUrl: appBasePath || '/',
|
||||
});
|
||||
}
|
||||
|
||||
function createSessionState(predicateContext: ExtensionPredicateContext) {
|
||||
const identityApi =
|
||||
signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi;
|
||||
// As soon as a real identity is available we swap the phase proxy over so
|
||||
// the finalized tree observes the same API instance.
|
||||
updateIdentityApiTarget(identityApi);
|
||||
const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis: phase.apis,
|
||||
identityApi,
|
||||
predicateContext,
|
||||
});
|
||||
return sessionState;
|
||||
}
|
||||
|
||||
function getSynchronousSessionState() {
|
||||
if (providedSessionState) {
|
||||
return providedSessionState;
|
||||
}
|
||||
// The direct finalize() path is intentionally synchronous. If sign-in is
|
||||
// still pending we refuse to guess and force the caller to wait.
|
||||
if (signInRuntime?.requiresSignIn) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const predicateContext = predicateContextLoader.getImmediate();
|
||||
if (!predicateContext) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return createSessionState(predicateContext);
|
||||
}
|
||||
|
||||
function loadAsyncSessionState() {
|
||||
if (providedSessionState) {
|
||||
return Promise.resolve(providedSessionState);
|
||||
}
|
||||
if (signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// For apps without sign-in we can sometimes finalize immediately from the
|
||||
// already available predicate context, skipping the async loader.
|
||||
if (!signInRuntime?.requiresSignIn) {
|
||||
const immediateSessionState = getSynchronousSessionState();
|
||||
if (immediateSessionState) {
|
||||
return Promise.resolve(immediateSessionState);
|
||||
}
|
||||
}
|
||||
|
||||
return predicateContextLoader.load().then(createSessionState);
|
||||
}
|
||||
|
||||
function finalizeWithSessionState(
|
||||
finalizedSessionState: SpecializedAppSessionState,
|
||||
) {
|
||||
return finalizeFromSessionState({
|
||||
finalized,
|
||||
finalizedSessionState,
|
||||
tree,
|
||||
collector,
|
||||
phase,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
routeRefsById,
|
||||
appBasePath,
|
||||
providedApis,
|
||||
features,
|
||||
appApiRegistry,
|
||||
bootstrapClassification,
|
||||
bootstrapApiFactoryEntries,
|
||||
bootstrapMissingApiAccesses,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeWithBootstrapError(
|
||||
error: Error,
|
||||
finalizedSessionState?: SpecializedAppSessionState,
|
||||
) {
|
||||
return finalizeFromBootstrapError({
|
||||
finalized,
|
||||
error,
|
||||
finalizedSessionState,
|
||||
tree,
|
||||
collector,
|
||||
phase,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
routeRefsById,
|
||||
signInRuntime,
|
||||
providedSessionData,
|
||||
});
|
||||
}
|
||||
|
||||
const finalization = createFinalizationController({
|
||||
getFinalized() {
|
||||
return finalized;
|
||||
},
|
||||
setFinalized(finalizedApp) {
|
||||
finalized = finalizedApp;
|
||||
},
|
||||
finalizeFromSessionState: finalizeWithSessionState,
|
||||
finalizeFromBootstrapError: finalizeWithBootstrapError,
|
||||
});
|
||||
|
||||
function getBootstrapApp() {
|
||||
if (bootstrapApp) {
|
||||
return bootstrapApp;
|
||||
}
|
||||
|
||||
const runtime: SignInRuntime = {
|
||||
requiresSignIn: false,
|
||||
};
|
||||
if (!providedSessionState) {
|
||||
phase.identityApiProxy.setTargetHandlers({
|
||||
onTargetSet(identityApi) {
|
||||
runtime.readyIdentityApi = identityApi;
|
||||
// Sign-in completion only auto-starts finalization for onFinalized().
|
||||
// The direct finalize() path stays explicit and synchronous.
|
||||
if (finalization.getMode() === 'onFinalized') {
|
||||
finalization.start(loadAsyncSessionState);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = createBootstrapApp({
|
||||
tree,
|
||||
apis: phase.apis,
|
||||
collector,
|
||||
routeRefsById,
|
||||
routeResolutionApi: phase.routeResolutionApi,
|
||||
appTreeApi: phase.appTreeApi,
|
||||
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
|
||||
disableSignIn: Boolean(providedSessionState),
|
||||
skipBootstrapChild({ child }) {
|
||||
return bootstrapClassification.deferredRoots.has(child);
|
||||
},
|
||||
onMissingApi({ node, apiRefId }) {
|
||||
bootstrapMissingApiAccesses.set(`${node.spec.id}:${apiRefId}`, {
|
||||
node,
|
||||
apiRefId,
|
||||
});
|
||||
},
|
||||
hasSignInPage(signInPageNode) {
|
||||
return Boolean(
|
||||
signInPageNode?.instance?.getData(signInPageComponentDataRef),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!result.requiresSignIn) {
|
||||
phase.identityApiProxy.clearTargetHandlers();
|
||||
}
|
||||
|
||||
runtime.requiresSignIn = result.requiresSignIn;
|
||||
signInRuntime = runtime;
|
||||
bootstrapApp = result.bootstrapApp;
|
||||
|
||||
return bootstrapApp;
|
||||
}
|
||||
|
||||
return {
|
||||
getBootstrapApp,
|
||||
onFinalized(callback) {
|
||||
finalization.selectMode('onFinalized');
|
||||
// Subscribing to finalization also ensures the bootstrap tree exists,
|
||||
// because sign-in may need to capture identity before finalization starts.
|
||||
getBootstrapApp();
|
||||
|
||||
let subscribed = true;
|
||||
|
||||
if (finalized) {
|
||||
const finalizedApp = finalized;
|
||||
Promise.resolve().then(() => {
|
||||
if (subscribed) {
|
||||
callback(finalizedApp);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
subscribed = false;
|
||||
};
|
||||
}
|
||||
|
||||
// If sign-in is still in progress we wait for the shared promise created
|
||||
// by the sign-in callback. Otherwise we can start finalization right away.
|
||||
const finalizedAppPromise =
|
||||
signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi
|
||||
? finalization.getPromise()
|
||||
: finalization.start(loadAsyncSessionState);
|
||||
finalizedAppPromise
|
||||
.then(finalizedApp => {
|
||||
if (subscribed) {
|
||||
callback(finalizedApp);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
subscribed = false;
|
||||
};
|
||||
},
|
||||
finalize() {
|
||||
finalization.selectMode('finalize');
|
||||
if (finalized) {
|
||||
return finalized;
|
||||
}
|
||||
if (!providedSessionState) {
|
||||
// finalize() still depends on bootstrap classification and sign-in
|
||||
// discovery unless a reusable session was supplied up front, so we make
|
||||
// sure the bootstrap tree has been prepared first.
|
||||
getBootstrapApp();
|
||||
}
|
||||
|
||||
// Direct finalization never waits for async session preparation. Callers
|
||||
// must either provide sessionState during prepareSpecializedApp() or
|
||||
// invoke finalize() only when the predicate context is already available
|
||||
// synchronously.
|
||||
const finalizedSessionState = signInRuntime?.requiresSignIn
|
||||
? undefined
|
||||
: getSynchronousSessionState();
|
||||
if (!finalizedSessionState) {
|
||||
if (signInRuntime?.requiresSignIn) {
|
||||
throw new Error(
|
||||
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'prepareSpecializedApp requires waiting for asynchronous finalization before calling finalize()',
|
||||
);
|
||||
}
|
||||
|
||||
finalized = finalizeWithSessionState(finalizedSessionState);
|
||||
return finalized;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes the fully finalized app tree from a prepared session state.
|
||||
*
|
||||
* This is responsible for switching the identity proxy to the resolved target,
|
||||
* synchronizing any deferred API factories, and re-instantiating the parts of
|
||||
* the tree that are only valid once predicate context is available.
|
||||
*/
|
||||
function finalizeFromSessionState(options: {
|
||||
finalized?: FinalizedSpecializedApp;
|
||||
finalizedSessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
phase: ReturnType<typeof createPhaseApis>;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
appBasePath: string;
|
||||
providedApis?: ApiHolder;
|
||||
features: FrontendFeature[];
|
||||
appApiRegistry: FrontendApiRegistry;
|
||||
bootstrapClassification: BootstrapClassification;
|
||||
bootstrapApiFactoryEntries: Map<string, ApiFactoryEntry>;
|
||||
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
|
||||
}): FinalizedSpecializedApp {
|
||||
if (options.finalized) {
|
||||
return options.finalized;
|
||||
}
|
||||
|
||||
const sessionStateData = OpaqueSpecializedAppSessionState.toInternal(
|
||||
options.finalizedSessionState,
|
||||
);
|
||||
if (sessionStateData.identityApi) {
|
||||
// Finalization retargets the identity proxy before any additional nodes are
|
||||
// instantiated so the full tree observes the captured identity immediately.
|
||||
setIdentityApiTarget({
|
||||
identityApiProxy: options.phase.identityApiProxy,
|
||||
identityApi: sessionStateData.identityApi,
|
||||
signOutTargetUrl: options.appBasePath || '/',
|
||||
});
|
||||
}
|
||||
if (!options.providedApis) {
|
||||
// Deferred API roots are synchronized at finalization time, but bootstrap-
|
||||
// materialized APIs stay frozen if they were already observed earlier.
|
||||
syncFinalApiFactories({
|
||||
deferredApiNodes: options.bootstrapClassification.deferredApiRoots,
|
||||
appApiRegistry: options.appApiRegistry,
|
||||
apiResolver: options.phase.apis,
|
||||
collector: options.collector,
|
||||
features: options.features,
|
||||
bootstrapApiFactoryEntries: options.bootstrapApiFactoryEntries,
|
||||
bootstrapMissingApiAccesses: options.bootstrapMissingApiAccesses,
|
||||
predicateContext: sessionStateData.predicateContext,
|
||||
});
|
||||
}
|
||||
|
||||
prepareFinalizedTree({
|
||||
tree: options.tree,
|
||||
});
|
||||
// Finalization re-instantiates the boundary subtree so predicate-gated app
|
||||
// content can be re-evaluated without disturbing preserved bootstrap nodes.
|
||||
clearFinalizationBoundaryInstances(options.tree);
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.phase.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.phase.routeResolutionApi,
|
||||
appTreeApi: options.phase.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
predicateContext: sessionStateData.predicateContext,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected finalized app tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
sessionState: options.finalizedSessionState,
|
||||
tree: options.tree,
|
||||
errors: options.collector.collectErrors(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a finalized app that rethrows a bootstrap-time failure through the
|
||||
* normal app root boundary.
|
||||
*
|
||||
* This keeps the error handling path aligned with normal finalization while
|
||||
* preserving any session state that was already resolved before the failure.
|
||||
*/
|
||||
function finalizeFromBootstrapError(options: {
|
||||
finalized?: FinalizedSpecializedApp;
|
||||
error: Error;
|
||||
finalizedSessionState?: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
phase: ReturnType<typeof createPhaseApis>;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
signInRuntime?: SignInRuntime;
|
||||
providedSessionData?: InternalSpecializedAppSessionState;
|
||||
}): FinalizedSpecializedApp {
|
||||
if (options.finalized) {
|
||||
return options.finalized;
|
||||
}
|
||||
|
||||
// If finalization fails after session state was already prepared, keep using
|
||||
// it so the error app reflects the same identity and API view.
|
||||
const finalizedSessionState =
|
||||
options.finalizedSessionState ??
|
||||
OpaqueSpecializedAppSessionState.createInstance('v1', {
|
||||
apis: options.phase.apis,
|
||||
identityApi:
|
||||
options.signInRuntime?.readyIdentityApi ??
|
||||
options.providedSessionData?.identityApi,
|
||||
predicateContext: EMPTY_PREDICATE_CONTEXT,
|
||||
});
|
||||
|
||||
prepareFinalizedTree({
|
||||
tree: options.tree,
|
||||
});
|
||||
clearFinalizationBoundaryInstances(options.tree);
|
||||
// The final app reports bootstrap failures through app/root.children so the
|
||||
// normal app root boundary renders the error state for us.
|
||||
attachThrowingFinalizationChild(options.tree, options.error);
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.phase.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.phase.routeResolutionApi,
|
||||
appTreeApi: options.phase.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected finalized app tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
sessionState: finalizedSessionState,
|
||||
tree: options.tree,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the callback-driven finalization lifecycle for a prepared app.
|
||||
*
|
||||
* The controller enforces the selected finalization mode, memoizes the shared
|
||||
* async finalization promise for `onFinalized()` subscribers, and funnels both
|
||||
* successful and failing async finalization through the same resolution path.
|
||||
*/
|
||||
function createFinalizationController(options: {
|
||||
getFinalized(): FinalizedSpecializedApp | undefined;
|
||||
setFinalized(finalizedApp: FinalizedSpecializedApp): void;
|
||||
finalizeFromSessionState(
|
||||
finalizedSessionState: SpecializedAppSessionState,
|
||||
): FinalizedSpecializedApp;
|
||||
finalizeFromBootstrapError(
|
||||
error: Error,
|
||||
finalizedSessionState?: SpecializedAppSessionState,
|
||||
): FinalizedSpecializedApp;
|
||||
}) {
|
||||
let finalizationState: FinalizationState | undefined;
|
||||
let finalizationMode: FinalizationMode | undefined;
|
||||
|
||||
function getState(): FinalizationState {
|
||||
if (finalizationState) {
|
||||
return finalizationState;
|
||||
}
|
||||
|
||||
// onFinalized() subscribers all fan into the same promise so that the full
|
||||
// finalization flow only ever runs once.
|
||||
let resolve: ((app: FinalizedSpecializedApp) => void) | undefined;
|
||||
let reject: ((error: unknown) => void) | undefined;
|
||||
const promise = new Promise<FinalizedSpecializedApp>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
if (!resolve || !reject) {
|
||||
throw new Error('Failed to create finalization state');
|
||||
}
|
||||
|
||||
finalizationState = {
|
||||
started: false,
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
return finalizationState;
|
||||
}
|
||||
|
||||
return {
|
||||
getMode() {
|
||||
return finalizationMode;
|
||||
},
|
||||
getPromise() {
|
||||
return getState().promise;
|
||||
},
|
||||
selectMode(mode: FinalizationMode) {
|
||||
if (finalizationMode && finalizationMode !== mode) {
|
||||
throw new Error(
|
||||
`prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`,
|
||||
);
|
||||
}
|
||||
|
||||
// A prepared app now has one owner: either the callback-driven path or
|
||||
// the direct finalize() path, never both.
|
||||
finalizationMode = mode;
|
||||
},
|
||||
start(loader: () => Promise<SpecializedAppSessionState>) {
|
||||
const finalized = options.getFinalized();
|
||||
if (finalized) {
|
||||
return Promise.resolve(finalized);
|
||||
}
|
||||
|
||||
const state = getState();
|
||||
if (state.started) {
|
||||
return state.promise;
|
||||
}
|
||||
state.started = true;
|
||||
|
||||
// If loading finishes but final tree materialization fails, we still
|
||||
// want to preserve the resolved session state when building the error app.
|
||||
let finalizedSessionState: SpecializedAppSessionState | undefined;
|
||||
loader()
|
||||
.then(sessionState => {
|
||||
finalizedSessionState = sessionState;
|
||||
const finalizedApp = options.finalizeFromSessionState(sessionState);
|
||||
options.setFinalized(finalizedApp);
|
||||
state.resolve(finalizedApp);
|
||||
})
|
||||
.catch(error => {
|
||||
try {
|
||||
const bootstrapFailure = isError(error)
|
||||
? error
|
||||
: new Error(String(error));
|
||||
const finalizedApp = options.finalizeFromBootstrapError(
|
||||
bootstrapFailure,
|
||||
finalizedSessionState,
|
||||
);
|
||||
options.setFinalized(finalizedApp);
|
||||
state.resolve(finalizedApp);
|
||||
} catch (finalizationError) {
|
||||
finalizationState = undefined;
|
||||
state.reject(finalizationError);
|
||||
}
|
||||
});
|
||||
|
||||
return state.promise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines one or more extension factory middlewares into a single middleware
|
||||
* invocation chain that preserves Backstage's extension data container shape.
|
||||
*/
|
||||
function mergeExtensionFactoryMiddleware(
|
||||
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
|
||||
): ExtensionFactoryMiddleware | undefined {
|
||||
if (!middlewares) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(middlewares)) {
|
||||
return middlewares;
|
||||
}
|
||||
if (middlewares.length <= 1) {
|
||||
return middlewares[0];
|
||||
}
|
||||
return middlewares.reduce((prev, next) => {
|
||||
if (!prev || !next) {
|
||||
return prev ?? next;
|
||||
}
|
||||
return (orig, ctx) => {
|
||||
const internalExt = toInternalExtension(ctx.node.spec.extension);
|
||||
if (internalExt.version !== 'v2') {
|
||||
return orig();
|
||||
}
|
||||
return next(ctxOverrides => {
|
||||
return createExtensionDataContainer(
|
||||
prev(orig, {
|
||||
node: ctx.node,
|
||||
apis: ctx.apis,
|
||||
config: ctxOverrides?.config ?? ctx.config,
|
||||
}),
|
||||
'extension factory middleware',
|
||||
);
|
||||
}, ctx);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
AppNodeInstance,
|
||||
AppTree,
|
||||
coreExtensionData,
|
||||
ExtensionDataRef,
|
||||
ExtensionFactoryMiddleware,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { ErrorCollector } from './createErrorCollector';
|
||||
import {
|
||||
AppTreeApiProxy,
|
||||
instantiateAndInitializePhaseTree,
|
||||
RouteResolutionApiProxy,
|
||||
} from './phaseApis';
|
||||
|
||||
export type BootstrapClassification = {
|
||||
deferredApiRoots: Set<AppNode>;
|
||||
deferredElementRoots: Set<AppNode>;
|
||||
deferredRoots: Set<AppNode>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiates the bootstrap-visible portion of the app tree and returns the
|
||||
* element that should be rendered while the prepared app is still incomplete.
|
||||
*
|
||||
* The bootstrap tree deliberately stops at the session boundary so sign-in and
|
||||
* other deferred content can be handled separately during finalization.
|
||||
*/
|
||||
export function createBootstrapApp(options: {
|
||||
tree: AppTree;
|
||||
apis: ApiHolder;
|
||||
collector: ErrorCollector;
|
||||
routeRefsById: ReturnType<typeof collectRouteIds>;
|
||||
routeResolutionApi: RouteResolutionApiProxy;
|
||||
appTreeApi: AppTreeApiProxy;
|
||||
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
|
||||
disableSignIn?: boolean;
|
||||
skipBootstrapChild?(ctx: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
child: AppNode;
|
||||
}): boolean;
|
||||
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
|
||||
hasSignInPage(node?: AppNode): boolean;
|
||||
}): {
|
||||
bootstrapApp: { element: JSX.Element; tree: AppTree };
|
||||
requiresSignIn: boolean;
|
||||
} {
|
||||
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
|
||||
'signInPage',
|
||||
)?.[0];
|
||||
|
||||
instantiateAndInitializePhaseTree({
|
||||
tree: options.tree,
|
||||
apis: options.apis,
|
||||
collector: options.collector,
|
||||
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
|
||||
routeResolutionApi: options.routeResolutionApi,
|
||||
appTreeApi: options.appTreeApi,
|
||||
routeRefsById: options.routeRefsById,
|
||||
stopAtAttachment: ({ node, input }) =>
|
||||
isSessionBoundaryAttachment(node, input),
|
||||
skipChild: options.skipBootstrapChild,
|
||||
onMissingApi: options.onMissingApi,
|
||||
});
|
||||
|
||||
const element = options.tree.root.instance?.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error('Expected bootstrap tree to expose a root element');
|
||||
}
|
||||
|
||||
return {
|
||||
bootstrapApp: {
|
||||
element,
|
||||
tree: options.tree,
|
||||
},
|
||||
requiresSignIn:
|
||||
!options.disableSignIn && options.hasSignInPage(signInPageNode),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the app tree into bootstrap-visible and deferred regions.
|
||||
*
|
||||
* Predicate-gated roots are deferred to finalization, while any predicate that
|
||||
* still leaks into the bootstrap-visible region is reported and ignored.
|
||||
*/
|
||||
export function classifyBootstrapTree(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): BootstrapClassification {
|
||||
const apiNodes = options.tree.root.edges.attachments.get('apis') ?? [];
|
||||
const deferredApiRoots = new Set(
|
||||
apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)),
|
||||
);
|
||||
const appRootElementNodes =
|
||||
getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? [];
|
||||
const deferredElementRoots = new Set(
|
||||
appRootElementNodes.filter(elementNode =>
|
||||
subtreeContainsPredicate(elementNode),
|
||||
),
|
||||
);
|
||||
const deferredRoots = new Set<AppNode>([
|
||||
...deferredApiRoots,
|
||||
...deferredElementRoots,
|
||||
]);
|
||||
const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, {
|
||||
deferredRoots,
|
||||
});
|
||||
|
||||
for (const node of bootstrapNodes) {
|
||||
if (node.spec.if === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
options.collector.report({
|
||||
code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
message:
|
||||
`Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` +
|
||||
"Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.",
|
||||
context: {
|
||||
node,
|
||||
},
|
||||
});
|
||||
(node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
deferredApiRoots,
|
||||
deferredElementRoots,
|
||||
deferredRoots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the app tree for finalization by removing the bootstrap-only
|
||||
* sign-in attachment from the app root boundary.
|
||||
*/
|
||||
export function prepareFinalizedTree(options: { tree: AppTree }) {
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) {
|
||||
const attachments = appRootNode.edges.attachments as Map<string, AppNode[]>;
|
||||
attachments.delete('signInPage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears instances inside the finalization boundary so those nodes can be
|
||||
* re-instantiated with finalized predicate context and API availability.
|
||||
*/
|
||||
export function clearFinalizationBoundaryInstances(tree: AppTree) {
|
||||
clearNodeInstance(tree.root);
|
||||
|
||||
const visited = new Set<AppNode>();
|
||||
function visit(node: AppNode) {
|
||||
if (visited.has(node)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node);
|
||||
clearNodeInstance(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
// app/root.elements is allowed to keep its bootstrap instances so we only
|
||||
// re-run the parts of the boundary that actually change at finalization.
|
||||
if (node.spec.id === 'app/root' && input === 'elements') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const appRootNode of getFinalizationBoundaryNodes(tree)) {
|
||||
visit(appRootNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies the attachment that separates bootstrap rendering from the
|
||||
* children that are deferred until finalization.
|
||||
*/
|
||||
export function isSessionBoundaryAttachment(node: AppNode, input: string) {
|
||||
return node.spec.id === 'app/root' && input === 'children';
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a synthetic finalization child that throws the captured bootstrap
|
||||
* error when rendered.
|
||||
*
|
||||
* This lets the finalized tree reuse the normal app root error boundary rather
|
||||
* than introducing a separate error rendering path.
|
||||
*/
|
||||
export function attachThrowingFinalizationChild(tree: AppTree, error: Error) {
|
||||
const bootstrapChildNode =
|
||||
getAppRootNode(tree)?.edges.attachments.get('children')?.[0];
|
||||
if (!bootstrapChildNode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function ThrowBootstrapError(): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This synthetic child gives the finalized tree a stable place to rethrow
|
||||
// bootstrap failures through the normal extension boundary stack.
|
||||
(bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = {
|
||||
getDataRefs() {
|
||||
return [coreExtensionData.reactElement];
|
||||
},
|
||||
getData<TValue>(dataRef: ExtensionDataRef<TValue>) {
|
||||
if (dataRef.id === coreExtensionData.reactElement.id) {
|
||||
return (<ThrowBootstrapError />) as TValue;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAppRootNode(tree: AppTree) {
|
||||
return tree.nodes.get('app/root');
|
||||
}
|
||||
|
||||
function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] {
|
||||
const nodes = new Set<AppNode>();
|
||||
const appRootNode = getAppRootNode(tree);
|
||||
if (appRootNode) {
|
||||
nodes.add(appRootNode);
|
||||
}
|
||||
const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0];
|
||||
if (attachedAppRootNode) {
|
||||
nodes.add(attachedAppRootNode);
|
||||
}
|
||||
return Array.from(nodes);
|
||||
}
|
||||
|
||||
function clearNodeInstance(node: AppNode) {
|
||||
(node as AppNode & { instance?: AppNodeInstance }).instance = undefined;
|
||||
}
|
||||
|
||||
function collectBootstrapVisibleNodes(
|
||||
tree: AppTree,
|
||||
options?: { deferredRoots?: Set<AppNode> },
|
||||
) {
|
||||
const visibleNodes = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode) {
|
||||
if (visibleNodes.has(node)) {
|
||||
return;
|
||||
}
|
||||
visibleNodes.add(node);
|
||||
|
||||
for (const [input, children] of node.edges.attachments) {
|
||||
if (isSessionBoundaryAttachment(node, input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const child of children) {
|
||||
if (options?.deferredRoots?.has(child)) {
|
||||
continue;
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(tree.root);
|
||||
|
||||
return visibleNodes;
|
||||
}
|
||||
|
||||
function subtreeContainsPredicate(root: AppNode) {
|
||||
const visited = new Set<AppNode>();
|
||||
|
||||
function visit(node: AppNode): boolean {
|
||||
if (visited.has(node)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(node);
|
||||
|
||||
if (node.spec.if !== undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const children of node.edges.attachments.values()) {
|
||||
for (const child of children) {
|
||||
if (visit(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return visit(root);
|
||||
}
|
||||
@@ -43,6 +43,8 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
|
||||
import {
|
||||
AppTreeApi,
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createApiRef,
|
||||
createExtensionDataRef,
|
||||
createExtension,
|
||||
createExtensionBlueprint,
|
||||
createExtensionInput,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
createFrontendFeatureLoader,
|
||||
@@ -27,12 +32,22 @@ import {
|
||||
FrontendPluginInfo,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { act, 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';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
describe('createApp', () => {
|
||||
const appPlugin = appPluginOriginal.withOverrides({
|
||||
@@ -43,6 +58,25 @@ describe('createApp', () => {
|
||||
],
|
||||
});
|
||||
|
||||
function createFeatureFlagsApi(activeFlags: string[]) {
|
||||
return {
|
||||
isActive: jest.fn((name: string) => activeFlags.includes(name)),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
}
|
||||
|
||||
function createPermissionApi(allowedPermissions: string[]) {
|
||||
return {
|
||||
authorize: jest.fn(async request => ({
|
||||
result: allowedPermissions.includes(request.permission.name)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
})),
|
||||
} as typeof permissionApiRef.T;
|
||||
}
|
||||
|
||||
it('should allow themes to be installed', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
@@ -84,6 +118,184 @@ 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 surface sign-in bootstrap errors through the app root boundary', async () => {
|
||||
const identityApi = {
|
||||
getProfileInfo: async () => ({ displayName: 'Test User' }),
|
||||
getBackstageIdentity: async () => ({
|
||||
type: 'user' as const,
|
||||
userEntityRef: 'user:default/test-user',
|
||||
ownershipEntityRefs: ['user:default/test-user'],
|
||||
}),
|
||||
getCredentials: async () => ({ token: 'token' }),
|
||||
signOut: async () => {},
|
||||
};
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn(() => {
|
||||
throw new Error('sign-in bootstrap failed');
|
||||
}),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
function SignInPage(props: {
|
||||
onSignInSuccess(identity: IdentityApi): void;
|
||||
}) {
|
||||
onSignInSuccess = props.onSignInSuccess;
|
||||
|
||||
return <div>Custom Sign In</div>;
|
||||
}
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Custom Sign In'),
|
||||
).resolves.toBeInTheDocument();
|
||||
if (!onSignInSuccess) {
|
||||
throw new Error('Expected sign-in success callback to be captured');
|
||||
}
|
||||
const triggerSignInSuccess = onSignInSuccess;
|
||||
act(() => {
|
||||
triggerSignInSuccess(identityApi);
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('sign-in bootstrap failed'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
@@ -283,47 +495,497 @@ describe('createApp', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should warn about unknown extension config', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
it('should evaluate extension if predicates before rendering apps without sign-in', async () => {
|
||||
const featureFlagsApi = {
|
||||
isActive: jest.fn((name: string) => name === 'test-flag'),
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: () => [],
|
||||
save: jest.fn(),
|
||||
} as unknown as typeof featureFlagsApiRef.T;
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => featureFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { featureFlags: { $contains: 'test-flag' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Derp</div>,
|
||||
loader: async () => <div>Flagged Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
configLoader: async () => ({
|
||||
config: mockApis.config({
|
||||
data: {
|
||||
app: {
|
||||
extensions: [{ 'unknown:lols/wut': false }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
await expect(
|
||||
screen.findByText('Flagged Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
|
||||
});
|
||||
|
||||
it('should support $all feature flag predicates on pages', async () => {
|
||||
const partialFlagsApi = createFeatureFlagsApi(['experimental-features']);
|
||||
const partialFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => partialFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const partialRender = await renderWithEffects(partialFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
partialRender.unmount();
|
||||
|
||||
const allFlagsApi = createFeatureFlagsApi([
|
||||
'experimental-features',
|
||||
'advanced-features',
|
||||
]);
|
||||
const allFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => allFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'advanced-features' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$all: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'advanced-features' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>All Flags Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allFlagsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('All Flags Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features');
|
||||
});
|
||||
|
||||
it('should support $any feature flag predicates on pages', async () => {
|
||||
const noFlagsApi = createFeatureFlagsApi([]);
|
||||
const noFlagsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => noFlagsApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
noFlagsRender.unmount();
|
||||
|
||||
const oneFlagApi = createFeatureFlagsApi(['beta-access']);
|
||||
const oneFlagApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => oneFlagApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [
|
||||
{ name: 'experimental-features' },
|
||||
{ name: 'beta-access' },
|
||||
],
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: {
|
||||
$any: [
|
||||
{ featureFlags: { $contains: 'experimental-features' } },
|
||||
{ featureFlags: { $contains: 'beta-access' } },
|
||||
],
|
||||
},
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Any Flag Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(oneFlagApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Any Flag Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features');
|
||||
expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access');
|
||||
});
|
||||
|
||||
it('should support permission predicates on pages', async () => {
|
||||
const deniedPermissionApi = createPermissionApi([]);
|
||||
const deniedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => deniedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const deniedRender = await renderWithEffects(deniedApp.createRoot());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(),
|
||||
);
|
||||
deniedRender.unmount();
|
||||
|
||||
const allowedPermissionApi = createPermissionApi(['catalog.entity.create']);
|
||||
const allowedApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => allowedPermissionApi,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
params: {
|
||||
path: '/',
|
||||
loader: async () => <div>Permission Page</div>,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(allowedApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Page'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({
|
||||
permission: {
|
||||
name: 'catalog.entity.create',
|
||||
type: 'basic',
|
||||
attributes: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should support conditional child extensions attached to pages', async () => {
|
||||
const CardBlueprint = createExtensionBlueprint({
|
||||
kind: 'card',
|
||||
attachTo: { id: 'page:test/card-page', input: 'cards' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
*factory(params: { title: string }) {
|
||||
yield coreExtensionData.reactElement(<div>{params.title}</div>);
|
||||
},
|
||||
});
|
||||
|
||||
const page = PageBlueprint.makeWithOverrides({
|
||||
name: 'card-page',
|
||||
inputs: {
|
||||
cards: createExtensionInput([coreExtensionData.reactElement], {
|
||||
optional: false,
|
||||
singleton: false,
|
||||
}),
|
||||
},
|
||||
factory(originalFactory, { inputs }) {
|
||||
return originalFactory({
|
||||
path: '/',
|
||||
loader: async () => (
|
||||
<div>
|
||||
{inputs.cards.map(card =>
|
||||
card.get(coreExtensionData.reactElement),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const publicCard = CardBlueprint.make({
|
||||
name: 'public',
|
||||
params: { title: 'Public Card' },
|
||||
});
|
||||
const permissionCard = CardBlueprint.make({
|
||||
name: 'permission',
|
||||
params: { title: 'Permission Card' },
|
||||
if: { permissions: { $contains: 'catalog.entity.create' } },
|
||||
});
|
||||
const featureFlagCard = CardBlueprint.make({
|
||||
name: 'feature-flag',
|
||||
params: { title: 'Feature Flag Card' },
|
||||
if: { featureFlags: { $contains: 'experimental-card' } },
|
||||
});
|
||||
|
||||
const hiddenCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi([]),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi([]),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const hiddenCardsRender = await renderWithEffects(
|
||||
hiddenCardsApp.createRoot(),
|
||||
);
|
||||
await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(),
|
||||
);
|
||||
hiddenCardsRender.unmount();
|
||||
|
||||
const visibleCardsApp = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPlugin,
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
name: 'permission-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: permissionApiRef,
|
||||
deps: {},
|
||||
factory: () => createPermissionApi(['catalog.entity.create']),
|
||||
}),
|
||||
}),
|
||||
ApiBlueprint.make({
|
||||
name: 'feature-flags-api',
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: featureFlagsApiRef,
|
||||
deps: {},
|
||||
factory: () => createFeatureFlagsApi(['experimental-card']),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'experimental-card' }],
|
||||
extensions: [page, publicCard, permissionCard, featureFlagCard],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(visibleCardsApp.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Permission Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Feature Flag Card'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
|
||||
@@ -428,9 +1090,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,10 @@
|
||||
* 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 +29,9 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
CreateAppRouteBinder,
|
||||
createSpecializedApp,
|
||||
ExtensionFactoryMiddleware,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
PreparedSpecializedApp,
|
||||
FrontendPluginInfoResolver,
|
||||
} from '@backstage/frontend-app-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
@@ -119,23 +120,16 @@ 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 };
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
return { default: () => rootEl };
|
||||
return {
|
||||
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
|
||||
};
|
||||
}
|
||||
|
||||
const LazyApp = lazy(appLoader);
|
||||
@@ -150,3 +144,28 @@ export function createApp(options?: CreateAppOptions): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PreparedAppRoot(props: {
|
||||
preparedApp: PreparedSpecializedApp;
|
||||
}): JSX.Element {
|
||||
const bootstrapApp = props.preparedApp.getBootstrapApp();
|
||||
const [finalizedApp, setFinalizedApp] = useState<
|
||||
FinalizedSpecializedApp | undefined
|
||||
>();
|
||||
|
||||
useEffect(
|
||||
() => props.preparedApp.onFinalized(setFinalizedApp),
|
||||
[props.preparedApp],
|
||||
);
|
||||
|
||||
if (!finalizedApp) {
|
||||
return bootstrapApp.element;
|
||||
}
|
||||
|
||||
const errorPage = maybeCreateErrorPage(finalizedApp);
|
||||
if (errorPage) {
|
||||
return errorPage;
|
||||
}
|
||||
|
||||
return finalizedApp.element;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
|
||||
'EXTENSION_INPUT_DATA_IGNORED',
|
||||
'EXTENSION_INPUT_INTERNAL_IGNORED',
|
||||
'EXTENSION_OUTPUT_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
|
||||
'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
|
||||
];
|
||||
|
||||
function AppErrorItem(props: { error: AppError }): JSX.Element {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
public: OverridableExtensionDefinition<ExtensionDefinitionParameters>;
|
||||
@@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
|
||||
readonly name?: string;
|
||||
readonly attachTo: ExtensionDefinitionAttachTo;
|
||||
readonly disabled: boolean;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly configSchema?: PortableSchema<any, any>;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconElement,
|
||||
OverridableFrontendPlugin,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
@@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
|
||||
readonly icon?: IconElement;
|
||||
readonly extensions: Extension<unknown>[];
|
||||
readonly featureFlags: FeatureFlagConfig[];
|
||||
readonly if?: FilterPredicate;
|
||||
readonly infoOptions?: {
|
||||
packageJson?: () => Promise<JsonObject>;
|
||||
manifest?: () => Promise<JsonObject>;
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
@@ -3,13 +3,576 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { Expand } from '@backstage/types';
|
||||
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import type { z } from 'zod';
|
||||
|
||||
// @public
|
||||
export type AnyRouteRefParams =
|
||||
| {
|
||||
[param in string]: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// @public
|
||||
export type ApiHolder = {
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ApiRef<T, TId extends string = string> = {
|
||||
readonly $$type?: '@backstage/ApiRef';
|
||||
readonly id: TId;
|
||||
readonly T: T;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface AppNode {
|
||||
readonly edges: AppNodeEdges;
|
||||
readonly instance?: AppNodeInstance;
|
||||
readonly spec: AppNodeSpec;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeEdges {
|
||||
// (undocumented)
|
||||
readonly attachedTo?: {
|
||||
node: AppNode;
|
||||
input: string;
|
||||
};
|
||||
// (undocumented)
|
||||
readonly attachments: ReadonlyMap<string, AppNode[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeInstance {
|
||||
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
|
||||
getDataRefs(): Iterable<ExtensionDataRef<unknown>>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
// (undocumented)
|
||||
readonly config?: unknown;
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
// (undocumented)
|
||||
readonly if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AppTree {
|
||||
readonly nodes: ReadonlyMap<string, AppNode>;
|
||||
readonly orphans: Iterable<AppNode>;
|
||||
readonly root: AppNode;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ConfigurableExtensionDataRef<
|
||||
TData,
|
||||
TId extends string,
|
||||
TConfig extends {
|
||||
optional?: true;
|
||||
} = {},
|
||||
> extends ExtensionDataRef<TData, TId, TConfig> {
|
||||
// (undocumented)
|
||||
(t: TData): ExtensionDataValue<TData, TId>;
|
||||
// (undocumented)
|
||||
optional(): ConfigurableExtensionDataRef<
|
||||
TData,
|
||||
TId,
|
||||
TConfig & {
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function createExtensionBlueprintParams<T extends object = object>(
|
||||
params: T,
|
||||
): ExtensionBlueprintParams<T>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/Extension';
|
||||
// (undocumented)
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
// (undocumented)
|
||||
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
|
||||
// (undocumented)
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionAttachTo = {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionBlueprint<
|
||||
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
|
||||
> {
|
||||
// (undocumented)
|
||||
dataRefs: T['dataRefs'];
|
||||
// (undocumented)
|
||||
make<
|
||||
TName extends string | undefined,
|
||||
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
>(args: {
|
||||
name?: TName;
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `<blueprint>.make({ params: defineParams => defineParams(<params>) })`'
|
||||
: T['params'];
|
||||
}): OverridableExtensionDefinition<{
|
||||
kind: T['kind'];
|
||||
name: string | undefined extends TName ? undefined : TName;
|
||||
config: T['config'];
|
||||
configInput: T['configInput'];
|
||||
output: T['output'];
|
||||
inputs: T['inputs'];
|
||||
params: T['params'];
|
||||
}>;
|
||||
makeWithOverrides<
|
||||
TName extends string | undefined,
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
},
|
||||
UFactoryOutput extends ExtensionDataValue<any, any>,
|
||||
UNewOutput extends ExtensionDataRef,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
TExtraInputs extends {
|
||||
[inputName in string]: ExtensionInput;
|
||||
} = {},
|
||||
>(args: {
|
||||
name?: TName;
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
};
|
||||
output?: Array<UNewOutput>;
|
||||
config?: {
|
||||
schema: TExtensionConfigSchema & {
|
||||
[KName in keyof T['config']]?: `Error: Config key '${KName &
|
||||
string}' is already defined in parent schema`;
|
||||
};
|
||||
};
|
||||
factory(
|
||||
originalFactory: <
|
||||
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
|
||||
>(
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: T['params'],
|
||||
context?: {
|
||||
config?: T['config'];
|
||||
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
|
||||
},
|
||||
) => ExtensionDataContainer<NonNullable<T['output']>>,
|
||||
context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
|
||||
},
|
||||
): Iterable<UFactoryOutput> &
|
||||
VerifyExtensionFactoryOutput<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UFactoryOutput
|
||||
>;
|
||||
}): OverridableExtensionDefinition<{
|
||||
config: Expand<
|
||||
(string extends keyof TExtensionConfigSchema
|
||||
? {}
|
||||
: {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
}) &
|
||||
T['config']
|
||||
>;
|
||||
configInput: Expand<
|
||||
(string extends keyof TExtensionConfigSchema
|
||||
? {}
|
||||
: z.input<
|
||||
z.ZodObject<{
|
||||
[key in keyof TExtensionConfigSchema]: ReturnType<
|
||||
TExtensionConfigSchema[key]
|
||||
>;
|
||||
}>
|
||||
>) &
|
||||
T['configInput']
|
||||
>;
|
||||
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
|
||||
inputs: Expand<T['inputs'] & TExtraInputs>;
|
||||
kind: T['kind'];
|
||||
name: string | undefined extends TName ? undefined : TName;
|
||||
params: T['params'];
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ExtensionBlueprintDefineParams<
|
||||
TParams extends object = object,
|
||||
TInput = any,
|
||||
> = (params: TInput) => ExtensionBlueprintParams<TParams>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionBlueprintParameters = {
|
||||
kind: string;
|
||||
params?: object | ExtensionBlueprintDefineParams;
|
||||
configInput?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
config?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
output?: ExtensionDataRef;
|
||||
inputs?: {
|
||||
[KName in string]: ExtensionInput;
|
||||
};
|
||||
dataRefs?: {
|
||||
[name in string]: ExtensionDataRef;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ExtensionBlueprintParams<T extends object = object> = {
|
||||
$$type: '@backstage/BlueprintParams';
|
||||
T: T;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataContainer<UExtensionData extends ExtensionDataRef> =
|
||||
Iterable<
|
||||
UExtensionData extends ExtensionDataRef<
|
||||
infer IData,
|
||||
infer IId,
|
||||
infer IConfig
|
||||
>
|
||||
? IConfig['optional'] extends true
|
||||
? never
|
||||
: ExtensionDataValue<IData, IId>
|
||||
: never
|
||||
> & {
|
||||
get<TId extends UExtensionData['id']>(
|
||||
ref: ExtensionDataRef<any, TId, any>,
|
||||
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
|
||||
? IConfig['optional'] extends true
|
||||
? IData | undefined
|
||||
: IData
|
||||
: never;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataRef<
|
||||
TData = unknown,
|
||||
TId extends string = string,
|
||||
TConfig extends {
|
||||
optional?: true;
|
||||
} = {
|
||||
optional?: true;
|
||||
},
|
||||
> = {
|
||||
readonly $$type: '@backstage/ExtensionDataRef';
|
||||
readonly id: TId;
|
||||
readonly T: TData;
|
||||
readonly config: TConfig;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataValue<TData, TId extends string> = {
|
||||
readonly $$type: '@backstage/ExtensionDataValue';
|
||||
readonly id: TId;
|
||||
readonly value: TData;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionDefinition<
|
||||
TParams extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ExtensionDefinitionAttachTo<
|
||||
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
|
||||
> =
|
||||
| {
|
||||
id: string;
|
||||
input: string;
|
||||
relative?: never;
|
||||
}
|
||||
| {
|
||||
relative: {
|
||||
kind?: string;
|
||||
name?: string;
|
||||
};
|
||||
input: string;
|
||||
id?: never;
|
||||
}
|
||||
| ExtensionInput<UParentInputs>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDefinitionParameters = {
|
||||
kind?: string;
|
||||
name?: string;
|
||||
configInput?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
config?: {
|
||||
[K in string]: any;
|
||||
};
|
||||
output?: ExtensionDataRef;
|
||||
inputs?: {
|
||||
[KName in string]: ExtensionInput;
|
||||
};
|
||||
params?: object | ExtensionBlueprintDefineParams;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionInput<
|
||||
UExtensionData extends ExtensionDataRef<
|
||||
unknown,
|
||||
string,
|
||||
{
|
||||
optional?: true;
|
||||
}
|
||||
> = ExtensionDataRef,
|
||||
TConfig extends {
|
||||
singleton: boolean;
|
||||
optional: boolean;
|
||||
internal?: boolean;
|
||||
} = {
|
||||
singleton: boolean;
|
||||
optional: boolean;
|
||||
internal?: boolean;
|
||||
},
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/ExtensionInput';
|
||||
// (undocumented)
|
||||
readonly config: TConfig;
|
||||
// (undocumented)
|
||||
readonly extensionData: Array<UExtensionData>;
|
||||
// (undocumented)
|
||||
readonly replaces?: Array<{
|
||||
id: string;
|
||||
input: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ExternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface FrontendPlugin<
|
||||
TRoutes extends {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
} = {
|
||||
[name in string]: RouteRef | SubRouteRef;
|
||||
},
|
||||
TExternalRoutes extends {
|
||||
[name in string]: ExternalRouteRef;
|
||||
} = {
|
||||
[name in string]: ExternalRouteRef;
|
||||
},
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/FrontendPlugin';
|
||||
// (undocumented)
|
||||
readonly externalRoutes: TExternalRoutes;
|
||||
readonly icon?: IconElement;
|
||||
// @deprecated
|
||||
readonly id: string;
|
||||
info(): Promise<FrontendPluginInfo>;
|
||||
readonly pluginId: string;
|
||||
// (undocumented)
|
||||
readonly routes: TRoutes;
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FrontendPluginInfo {
|
||||
description?: string;
|
||||
links?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
ownerEntityRefs?: string[];
|
||||
packageName?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type IconElement = JSX_2.Element | null;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OverridableExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> extends ExtensionDefinition<T> {
|
||||
readonly inputs: {
|
||||
[K in keyof T['inputs']]: ExtensionInput<
|
||||
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
|
||||
>;
|
||||
};
|
||||
// (undocumented)
|
||||
override<
|
||||
TExtensionConfigSchema extends {
|
||||
[key in string]: (zImpl: typeof z) => z.ZodType;
|
||||
},
|
||||
UFactoryOutput extends ExtensionDataValue<any, any>,
|
||||
UNewOutput extends ExtensionDataRef,
|
||||
TExtraInputs extends {
|
||||
[inputName in string]: ExtensionInput;
|
||||
},
|
||||
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
|
||||
UParentInputs extends ExtensionDataRef,
|
||||
>(
|
||||
args: Expand<
|
||||
{
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
};
|
||||
output?: Array<UNewOutput>;
|
||||
config?: {
|
||||
schema: TExtensionConfigSchema & {
|
||||
[KName in keyof T['config']]?: `Error: Config key '${KName &
|
||||
string}' is already defined in parent schema`;
|
||||
};
|
||||
};
|
||||
factory?(
|
||||
originalFactory: <
|
||||
TFactoryParamsReturn extends AnyParamsInput_2<
|
||||
NonNullable<T['params']>
|
||||
>,
|
||||
>(
|
||||
context?: Expand<
|
||||
{
|
||||
config?: T['config'];
|
||||
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
|
||||
} & ([T['params']] extends [never]
|
||||
? {}
|
||||
: {
|
||||
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
|
||||
? TFactoryParamsReturn
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: Partial<T['params']>;
|
||||
})
|
||||
>,
|
||||
) => ExtensionDataContainer<NonNullable<T['output']>>,
|
||||
context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
|
||||
},
|
||||
): Iterable<UFactoryOutput>;
|
||||
} & ([T['params']] extends [never]
|
||||
? {}
|
||||
: {
|
||||
params?: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
|
||||
: Partial<T['params']>;
|
||||
})
|
||||
> &
|
||||
VerifyExtensionFactoryOutput<
|
||||
ExtensionDataRef extends UNewOutput
|
||||
? NonNullable<T['output']>
|
||||
: UNewOutput,
|
||||
UFactoryOutput
|
||||
>,
|
||||
): OverridableExtensionDefinition<{
|
||||
kind: T['kind'];
|
||||
name: T['name'];
|
||||
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
|
||||
inputs: T['inputs'] & TExtraInputs;
|
||||
config: T['config'] & {
|
||||
[key in keyof TExtensionConfigSchema]: z.infer<
|
||||
ReturnType<TExtensionConfigSchema[key]>
|
||||
>;
|
||||
};
|
||||
configInput: T['configInput'] &
|
||||
z.input<
|
||||
z.ZodObject<{
|
||||
[key in keyof TExtensionConfigSchema]: ReturnType<
|
||||
TExtensionConfigSchema[key]
|
||||
>;
|
||||
}>
|
||||
>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PluginWrapperApi = {
|
||||
@@ -24,7 +587,7 @@ export type PluginWrapperApi = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export const pluginWrapperApiRef: ApiRef<
|
||||
export const pluginWrapperApiRef: ApiRef_2<
|
||||
PluginWrapperApi,
|
||||
'core.plugin-wrapper'
|
||||
> & {
|
||||
@@ -32,14 +595,14 @@ export const pluginWrapperApiRef: ApiRef<
|
||||
};
|
||||
|
||||
// @public
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
|
||||
kind: 'plugin-wrapper';
|
||||
params: <TValue = never>(params: {
|
||||
loader: () => Promise<PluginWrapperDefinition<TValue>>;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
}) => ExtensionBlueprintParams_2<{
|
||||
loader: () => Promise<PluginWrapperDefinition>;
|
||||
}>;
|
||||
output: ExtensionDataRef<
|
||||
output: ExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
@@ -48,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
config: {};
|
||||
configInput: {};
|
||||
dataRefs: {
|
||||
wrapper: ConfigurableExtensionDataRef<
|
||||
wrapper: ConfigurableExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
@@ -65,5 +628,33 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
}>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
parse: (input: TInput) => TOutput;
|
||||
schema: JsonObject;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface RouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/RouteRef';
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface SubRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> {
|
||||
// (undocumented)
|
||||
readonly $$type: '@backstage/SubRouteRef';
|
||||
// (undocumented)
|
||||
readonly path: string;
|
||||
// (undocumented)
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-
|
||||
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
@@ -258,6 +259,8 @@ export interface AppNodeSpec {
|
||||
// (undocumented)
|
||||
readonly id: string;
|
||||
// (undocumented)
|
||||
readonly if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -663,6 +667,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -751,6 +756,8 @@ export interface CreateFrontendModuleOptions<
|
||||
// (undocumented)
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
// (undocumented)
|
||||
if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
pluginId: TPluginId;
|
||||
}
|
||||
|
||||
@@ -796,6 +803,8 @@ export interface CreateFrontendPluginOptions<
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
icon?: IconElement;
|
||||
// (undocumented)
|
||||
if?: FilterPredicate;
|
||||
// (undocumented)
|
||||
info?: FrontendPluginInfoOptions;
|
||||
// (undocumented)
|
||||
pluginId: TId;
|
||||
@@ -1026,6 +1035,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -1061,6 +1071,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -1702,6 +1713,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -1807,6 +1819,7 @@ export interface OverridableFrontendPlugin<
|
||||
// (undocumented)
|
||||
withOverrides(options: {
|
||||
extensions?: Array<ExtensionDefinition>;
|
||||
if?: FilterPredicate;
|
||||
title?: string;
|
||||
icon?: IconElement;
|
||||
info?: FrontendPluginInfoOptions;
|
||||
|
||||
@@ -20,6 +20,43 @@ export {
|
||||
PluginWrapperBlueprint,
|
||||
type PluginWrapperDefinition,
|
||||
} from './blueprints/PluginWrapperBlueprint';
|
||||
export type {
|
||||
ConfigurableExtensionDataRef,
|
||||
Extension,
|
||||
ExtensionAttachTo,
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
ExtensionBlueprintDefineParams,
|
||||
ExtensionBlueprint,
|
||||
ExtensionBlueprintParameters,
|
||||
ExtensionBlueprintParams,
|
||||
ExtensionDataContainer,
|
||||
ExtensionDataRef,
|
||||
ExtensionDataValue,
|
||||
ExtensionDefinitionAttachTo,
|
||||
ExtensionInput,
|
||||
FrontendPlugin,
|
||||
OverridableExtensionDefinition,
|
||||
} from './wiring';
|
||||
export type {
|
||||
ApiHolder,
|
||||
ApiRef,
|
||||
AppNode,
|
||||
AppNodeEdges,
|
||||
AppNodeInstance,
|
||||
AppNodeSpec,
|
||||
AppTree,
|
||||
} from './apis';
|
||||
export type { PortableSchema } from './schema';
|
||||
export type {
|
||||
AnyRouteRefParams,
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
ExternalRouteRef,
|
||||
} from './routing';
|
||||
export type { IconElement } from './icons';
|
||||
export type { FrontendPluginInfo } from './wiring';
|
||||
export { createExtensionBlueprintParams } from './wiring';
|
||||
export {
|
||||
type PluginWrapperApi,
|
||||
pluginWrapperApiRef,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { createApiRef } from '../system';
|
||||
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
|
||||
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* The specification for this {@link AppNode} in the {@link AppTree}.
|
||||
@@ -32,6 +33,7 @@ export interface AppNodeSpec {
|
||||
readonly attachTo: ExtensionAttachTo;
|
||||
readonly extension: Extension<unknown, unknown>;
|
||||
readonly disabled: boolean;
|
||||
readonly if?: FilterPredicate;
|
||||
readonly config?: unknown;
|
||||
readonly plugin: FrontendPlugin;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => {
|
||||
"configSchema": undefined,
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": "analytics",
|
||||
"name": "test",
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('ApiBlueprint', () => {
|
||||
"configSchema": undefined,
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": "api",
|
||||
"name": "test",
|
||||
@@ -196,6 +197,7 @@ describe('ApiBlueprint', () => {
|
||||
},
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {
|
||||
"test": {
|
||||
"$$type": "@backstage/ExtensionInput",
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('AppRootElementBlueprint', () => {
|
||||
"configSchema": undefined,
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": "app-root-element",
|
||||
"name": undefined,
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('NavItemBlueprint', () => {
|
||||
},
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": "nav-item",
|
||||
"name": undefined,
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('PageBlueprint', () => {
|
||||
},
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {
|
||||
"pages": {
|
||||
"$$type": "@backstage/ExtensionInput",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './createExtensionBlueprint';
|
||||
import { FrontendPlugin } from './createFrontendPlugin';
|
||||
import { FrontendModule } from './createFrontendModule';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* This symbol is used to pass parameter overrides from the extension override to the blueprint factory
|
||||
@@ -174,6 +175,7 @@ export type CreateExtensionOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -255,6 +257,7 @@ export interface OverridableExtensionDefinition<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -474,6 +477,7 @@ export function createExtension<
|
||||
name: options.name,
|
||||
attachTo: options.attachTo,
|
||||
disabled: options.disabled ?? false,
|
||||
if: options.if,
|
||||
inputs: bindInputs(options.inputs, options.kind, options.name),
|
||||
output: options.output,
|
||||
configSchema,
|
||||
@@ -545,12 +549,18 @@ export function createExtension<
|
||||
);
|
||||
}
|
||||
|
||||
let ifPredicate = options.if;
|
||||
if ('if' in overrideOptions) {
|
||||
ifPredicate = overrideOptions.if;
|
||||
}
|
||||
|
||||
return createExtension({
|
||||
kind: options.kind,
|
||||
name: options.name,
|
||||
attachTo: (overrideOptions.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: overrideOptions.disabled ?? options.disabled,
|
||||
if: ifPredicate,
|
||||
inputs: bindInputs(
|
||||
{
|
||||
...(options.inputs ?? {}),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './resolveInputOverrides';
|
||||
import { ExtensionDataContainer } from './types';
|
||||
import { PageBlueprint } from '../blueprints/PageBlueprint';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* A function used to define a parameter mapping function in order to facilitate
|
||||
@@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions<
|
||||
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<UOutput, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TInputs;
|
||||
output: Array<UOutput>;
|
||||
config?: {
|
||||
@@ -221,6 +223,7 @@ export interface ExtensionBlueprint<
|
||||
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
|
||||
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
params: TParamsInput extends ExtensionBlueprintDefineParams
|
||||
? TParamsInput
|
||||
: T['params'] extends ExtensionBlueprintDefineParams
|
||||
@@ -261,6 +264,7 @@ export interface ExtensionBlueprint<
|
||||
UParentInputs
|
||||
>;
|
||||
disabled?: boolean;
|
||||
if?: FilterPredicate;
|
||||
inputs?: TExtraInputs & {
|
||||
[KName in keyof T['inputs']]?: `Error: Input '${KName &
|
||||
string}' is already defined in parent definition`;
|
||||
@@ -510,6 +514,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
if: args.if ?? options.if,
|
||||
inputs: options.inputs,
|
||||
output: options.output as ExtensionDataRef[],
|
||||
config: options.config,
|
||||
@@ -527,6 +532,7 @@ export function createExtensionBlueprint<
|
||||
attachTo: (args.attachTo ??
|
||||
options.attachTo) as ExtensionDefinitionAttachTo,
|
||||
disabled: args.disabled ?? options.disabled,
|
||||
if: args.if ?? options.if,
|
||||
inputs: { ...args.inputs, ...options.inputs },
|
||||
output: (args.output ?? options.output) as ExtensionDataRef[],
|
||||
config:
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('createFrontendModule', () => {
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"id": "route:test/test",
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"output": [],
|
||||
"toString": [Function],
|
||||
@@ -53,6 +54,7 @@ describe('createFrontendModule', () => {
|
||||
},
|
||||
],
|
||||
"featureFlags": [],
|
||||
"if": undefined,
|
||||
"pluginId": "test",
|
||||
"toString": [Function],
|
||||
"version": "v1",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveExtensionDefinition,
|
||||
} from './resolveExtensionDefinition';
|
||||
import { FeatureFlagConfig } from './types';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/** @public */
|
||||
export interface CreateFrontendModuleOptions<
|
||||
@@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions<
|
||||
pluginId: TPluginId;
|
||||
extensions?: TExtensions;
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
if?: FilterPredicate;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule {
|
||||
readonly version: 'v1';
|
||||
readonly extensions: Extension<unknown>[];
|
||||
readonly featureFlags: FeatureFlagConfig[];
|
||||
readonly if?: FilterPredicate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +129,7 @@ export function createFrontendModule<
|
||||
version: 'v1',
|
||||
pluginId,
|
||||
featureFlags: options.featureFlags ?? [],
|
||||
if: options.if,
|
||||
extensions,
|
||||
toString() {
|
||||
return `Module{pluginId=${pluginId}}`;
|
||||
|
||||
@@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => {
|
||||
"configSchema": undefined,
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": undefined,
|
||||
"name": "1",
|
||||
@@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => {
|
||||
"configSchema": undefined,
|
||||
"disabled": false,
|
||||
"factory": [Function],
|
||||
"if": undefined,
|
||||
"inputs": {},
|
||||
"kind": undefined,
|
||||
"name": "1",
|
||||
|
||||
@@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types';
|
||||
import { IconElement } from '../icons/types';
|
||||
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
|
||||
import { ID_PATTERN } from './constants';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* Information about the plugin.
|
||||
@@ -115,6 +116,11 @@ export interface OverridableFrontendPlugin<
|
||||
withOverrides(options: {
|
||||
extensions?: Array<ExtensionDefinition>;
|
||||
|
||||
/**
|
||||
* Overrides the shared condition that applies to all extensions in the plugin.
|
||||
*/
|
||||
if?: FilterPredicate;
|
||||
|
||||
/**
|
||||
* Overrides the display title of the plugin.
|
||||
*/
|
||||
@@ -195,6 +201,7 @@ export interface CreateFrontendPluginOptions<
|
||||
externalRoutes?: TExternalRoutes;
|
||||
extensions?: TExtensions;
|
||||
featureFlags?: FeatureFlagConfig[];
|
||||
if?: FilterPredicate;
|
||||
info?: FrontendPluginInfoOptions;
|
||||
}
|
||||
|
||||
@@ -304,6 +311,7 @@ export function createFrontendPlugin<
|
||||
routes: options.routes ?? ({} as TRoutes),
|
||||
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
|
||||
featureFlags: options.featureFlags ?? [],
|
||||
if: options.if,
|
||||
extensions: extensions,
|
||||
infoOptions: options.info,
|
||||
|
||||
@@ -326,6 +334,10 @@ export function createFrontendPlugin<
|
||||
return `Plugin{id=${pluginId}}`;
|
||||
},
|
||||
withOverrides(overrides) {
|
||||
let ifPredicate = options.if;
|
||||
if ('if' in overrides) {
|
||||
ifPredicate = overrides.if;
|
||||
}
|
||||
const overrideExtensions = overrides.extensions ?? [];
|
||||
const overriddenExtensionIds = new Set(
|
||||
overrideExtensions.map(
|
||||
@@ -341,6 +353,7 @@ export function createFrontendPlugin<
|
||||
return createFrontendPlugin({
|
||||
...options,
|
||||
pluginId,
|
||||
if: ifPredicate,
|
||||
title: overrides.title ?? options.title,
|
||||
icon: overrides.icon ?? options.icon,
|
||||
extensions: [...nonOverriddenExtensions, ...overrideExtensions],
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
OpaqueExtensionDefinition,
|
||||
OpaqueExtensionInput,
|
||||
} from '@internal/frontend';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/** @public */
|
||||
export type ExtensionAttachTo = { id: string; input: string };
|
||||
@@ -74,6 +75,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
|
||||
}
|
||||
| {
|
||||
readonly version: 'v2';
|
||||
readonly if?: FilterPredicate;
|
||||
readonly inputs: { [inputName in string]: ExtensionInput };
|
||||
readonly output: Array<ExtensionDataRef>;
|
||||
factory(options: {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-app-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow partial API implementations', () => {
|
||||
@@ -46,6 +48,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reject mismatched types in tuple syntax', () => {
|
||||
@@ -55,6 +59,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept MockWithApiFactory entries', () => {
|
||||
@@ -63,6 +69,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should accept a mix of tuples and MockWithApiFactory entries', () => {
|
||||
@@ -71,6 +79,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow empty APIs', () => {
|
||||
@@ -79,6 +89,8 @@ describe('TestApiProvider', () => {
|
||||
<div />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide APIs at runtime', async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Link, MemoryRouter } from 'react-router-dom';
|
||||
import { createSpecializedApp } from '@backstage/frontend-app-api';
|
||||
import { prepareSpecializedApp } from '@backstage/frontend-app-api';
|
||||
import { RenderResult, render } from '@testing-library/react';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
@@ -233,7 +233,7 @@ export function renderInTestApp<const TApiPairs extends any[] = any[]>(
|
||||
features.push(...options.features);
|
||||
}
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const app = prepareSpecializedApp({
|
||||
features,
|
||||
config: ConfigReader.fromConfigs([
|
||||
{
|
||||
@@ -251,7 +251,7 @@ export function renderInTestApp<const TApiPairs extends any[] = any[]>(
|
||||
return createApiFactory(apiRef, implementation);
|
||||
}),
|
||||
},
|
||||
} as CreateSpecializedAppInternalOptions);
|
||||
} as CreateSpecializedAppInternalOptions).finalize();
|
||||
|
||||
return render(
|
||||
app.tree.root.instance!.getData(coreExtensionData.reactElement),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { createSpecializedApp } from '@backstage/frontend-app-api';
|
||||
import { prepareSpecializedApp } from '@backstage/frontend-app-api';
|
||||
import {
|
||||
coreExtensionData,
|
||||
createApiFactory,
|
||||
@@ -175,7 +175,7 @@ export function renderTestApp<const TApiPairs extends any[] = any[]>(
|
||||
features.push(...options.features);
|
||||
}
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const app = prepareSpecializedApp({
|
||||
features,
|
||||
config: ConfigReader.fromConfigs([
|
||||
{
|
||||
@@ -193,7 +193,7 @@ export function renderTestApp<const TApiPairs extends any[] = any[]>(
|
||||
return createApiFactory(apiRef, implementation);
|
||||
}),
|
||||
},
|
||||
} as CreateSpecializedAppInternalOptions);
|
||||
} as CreateSpecializedAppInternalOptions).finalize();
|
||||
|
||||
return render(
|
||||
app.tree.root.instance!.getData(coreExtensionData.reactElement),
|
||||
|
||||
Reference in New Issue
Block a user