frontend-app-api: add support for installing extension overrides

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-09 19:23:12 +02:00
parent c1e9ca6500
commit d920b8c343
12 changed files with 159 additions and 66 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': minor
---
Added support for installing `ExtensionOverrides` via `createApp` options. As part of this change the `plugins` option has been renamed to `features`, and the `pluginLoader` has been renamed to `featureLoader`.
+1 -1
View File
@@ -49,7 +49,7 @@ TODO:
/* app.tsx */
const app = createApp({
plugins: [graphiqlPlugin, pagesPlugin, techRadarPlugin],
features: [graphiqlPlugin, pagesPlugin, techRadarPlugin],
// bindRoutes({ bind }) {
// bind(catalogPlugin.externalRoutes, {
// createComponent: scaffolderPlugin.routes.root,
+5 -2
View File
@@ -7,13 +7,16 @@ import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
// @public (undocumented)
export function createApp(options: {
plugins: BackstagePlugin[];
features?: (BackstagePlugin | ExtensionOverrides)[];
configLoader?: () => Promise<ConfigApi>;
pluginLoader?: (ctx: { config: ConfigApi }) => Promise<BackstagePlugin[]>;
featureLoader?: (ctx: {
config: ConfigApi;
}) => Promise<(BackstagePlugin | ExtensionOverrides)[]>;
}): {
createRoot(): JSX_2.Element;
};
@@ -76,7 +76,7 @@ function routeInfoFromExtensions(extensions: Extension<unknown>[]) {
});
const { rootInstances } = createInstances({
config: new MockConfigApi({}),
plugins: [plugin],
features: [plugin],
});
return extractRouteInfoFromInstanceTree(rootInstances);
@@ -38,20 +38,20 @@ describe('createInstances', () => {
],
},
});
const plugins = [
const features = [
createPlugin({
id: 'plugin',
extensions: [],
}),
];
expect(() => createInstances({ config, plugins })).toThrow(
expect(() => createInstances({ config, features })).toThrow(
"A 'root' extension configuration was detected, but the root extension is not configurable",
);
});
it('throws an error when a root extension is overridden', () => {
const config = new MockConfigApi({});
const plugins = [
const features = [
createPlugin({
id: 'plugin',
extensions: [
@@ -65,7 +65,7 @@ describe('createInstances', () => {
],
}),
];
expect(() => createInstances({ config, plugins })).toThrow(
expect(() => createInstances({ config, features })).toThrow(
"The following plugin(s) are overriding the 'root' extension which is forbidden: plugin",
);
});
@@ -97,9 +97,9 @@ describe('createInstances', () => {
extensions: [ExtensionA, ExtensionB, ExtensionB],
});
const plugins = [PluginA, PluginB];
const features = [PluginA, PluginB];
expect(() => createInstances({ config, plugins })).toThrow(
expect(() => createInstances({ config, features })).toThrow(
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
);
});
@@ -114,7 +114,7 @@ describe('createApp', () => {
extensions: [{ 'themes.light': false }, { 'themes.dark': false }],
},
}),
plugins: [
features: [
createPlugin({
id: 'test',
extensions: [
@@ -137,7 +137,7 @@ describe('createApp', () => {
it('should log an app', () => {
const { rootInstances } = createInstances({
config: new MockConfigApi({}),
plugins: [],
features: [],
});
const root = createExtensionInstance({
extension: createExtension({
@@ -175,7 +175,7 @@ describe('createApp', () => {
it('should serialize an app as JSON', () => {
const { rootInstances } = createInstances({
config: new MockConfigApi({}),
plugins: [],
features: [],
});
const root = createExtensionInstance({
extension: createExtension({
@@ -20,6 +20,7 @@ import {
BackstagePlugin,
coreExtensionData,
ExtensionDataRef,
ExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { Core } from '../extensions/Core';
import { CoreRoutes } from '../extensions/CoreRoutes';
@@ -51,7 +52,7 @@ import {
identityApiRef,
AppTheme,
} from '@backstage/core-plugin-api';
import { getAvailablePlugins } from './discovery';
import { getAvailableFeatures } from './discovery';
import {
ApiFactoryRegistry,
ApiProvider,
@@ -104,9 +105,9 @@ export interface ExtensionTree {
export function createExtensionTree(options: {
config: Config;
}): ExtensionTree {
const plugins = getAvailablePlugins();
const features = getAvailableFeatures();
const { instances } = createInstances({
plugins,
features,
config: options.config,
});
@@ -172,7 +173,7 @@ export function createExtensionTree(options: {
* @internal
*/
export function createInstances(options: {
plugins: BackstagePlugin[];
features: (BackstagePlugin | ExtensionOverrides)[];
config: Config;
}) {
const builtinExtensions = [
@@ -187,7 +188,7 @@ export function createInstances(options: {
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionParams = mergeExtensionParameters({
sources: options.plugins,
features: options.features,
builtinExtensions,
parameters: readAppExtensionParameters(options.config),
});
@@ -260,9 +261,11 @@ export function createInstances(options: {
/** @public */
export function createApp(options: {
plugins: BackstagePlugin[];
features?: (BackstagePlugin | ExtensionOverrides)[];
configLoader?: () => Promise<ConfigApi>;
pluginLoader?: (ctx: { config: ConfigApi }) => Promise<BackstagePlugin[]>;
featureLoader?: (ctx: {
config: ConfigApi;
}) => Promise<(BackstagePlugin | ExtensionOverrides)[]>;
}): {
createRoot(): JSX.Element;
} {
@@ -273,14 +276,18 @@ export function createApp(options: {
overrideBaseUrlConfigs(defaultConfigLoaderSync()),
);
const discoveredPlugins = getAvailablePlugins();
const loadedPlugins = (await options.pluginLoader?.({ config })) ?? [];
const allPlugins = Array.from(
new Set([...discoveredPlugins, ...options.plugins, ...loadedPlugins]),
const discoveredFeatures = getAvailableFeatures();
const loadedFeatures = (await options.featureLoader?.({ config })) ?? [];
const allFeatures = Array.from(
new Set([
...discoveredFeatures,
...(options.features ?? []),
...loadedFeatures,
]),
);
const { rootInstances } = createInstances({
plugins: allPlugins,
features: allFeatures,
config,
});
@@ -293,7 +300,11 @@ export function createApp(options: {
const apiHolder = createApiHolder(coreInstance, config);
const appContext = createLegacyAppContext(allPlugins);
const appContext = createLegacyAppContext(
allFeatures.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
),
);
const rootElements = rootInstances
.map(e => (
@@ -15,25 +15,25 @@
*/
import { createPlugin } from '@backstage/frontend-plugin-api';
import { getAvailablePlugins } from './discovery';
import { getAvailableFeatures } from './discovery';
const globalSpy = jest.fn();
Object.defineProperty(global, '__@backstage/discovered__', {
get: globalSpy,
});
describe('getAvailablePlugins', () => {
describe('getAvailableFeatures', () => {
afterEach(jest.resetAllMocks);
it('should discover nothing with undefined global', () => {
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
});
it('should discover nothing with empty global', () => {
globalSpy.mockReturnValue({
modules: [],
});
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
});
it('should discover a plugin', () => {
@@ -41,24 +41,24 @@ describe('getAvailablePlugins', () => {
globalSpy.mockReturnValue({
modules: [{ default: testPlugin }],
});
expect(getAvailablePlugins()).toEqual([testPlugin]);
expect(getAvailableFeatures()).toEqual([testPlugin]);
});
it('should ignore garbage', () => {
globalSpy.mockReturnValueOnce({ modules: [{ default: null }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: undefined }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: Symbol() }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: () => {} }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: 0 }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: false }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
globalSpy.mockReturnValueOnce({ modules: [{ default: true }] });
expect(getAvailablePlugins()).toEqual([]);
expect(getAvailableFeatures()).toEqual([]);
});
it('should discover multiple plugins', () => {
@@ -72,7 +72,7 @@ describe('getAvailablePlugins', () => {
{ default: test3Plugin },
],
});
expect(getAvailablePlugins()).toEqual([
expect(getAvailableFeatures()).toEqual([
test1Plugin,
test2Plugin,
test3Plugin,
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import {
BackstagePlugin,
ExtensionOverrides,
} from '@backstage/frontend-plugin-api';
interface DiscoveryGlobal {
modules: Array<{ name: string; default: unknown }>;
@@ -23,19 +26,27 @@ interface DiscoveryGlobal {
/**
* @public
*/
export function getAvailablePlugins(): BackstagePlugin[] {
export function getAvailableFeatures(): (
| BackstagePlugin
| ExtensionOverrides
)[] {
const discovered = (
window as { '__@backstage/discovered__'?: DiscoveryGlobal }
)['__@backstage/discovered__'];
return (
discovered?.modules.map(m => m.default).filter(isBackstagePlugin) ?? []
discovered?.modules.map(m => m.default).filter(isBackstageFeature) ?? []
);
}
function isBackstagePlugin(obj: unknown): obj is BackstagePlugin {
function isBackstageFeature(
obj: unknown,
): obj is BackstagePlugin | ExtensionOverrides {
if (obj !== null && typeof obj === 'object' && '$$type' in obj) {
return obj.$$type === '@backstage/BackstagePlugin';
return (
obj.$$type === '@backstage/BackstagePlugin' ||
obj.$$type === '@backstage/ExtensionOverrides'
);
}
return false;
}
@@ -35,7 +35,7 @@ describe('mergeExtensionParameters', () => {
it('should filter out disabled extension instances', () => {
expect(
mergeExtensionParameters({
sources: [],
features: [],
builtinExtensions: [makeExt('a', 'disabled')],
parameters: [],
}),
@@ -47,7 +47,7 @@ describe('mergeExtensionParameters', () => {
const b = makeExt('b');
expect(
mergeExtensionParameters({
sources: [],
features: [],
builtinExtensions: [a, b],
parameters: [],
}),
@@ -63,7 +63,7 @@ describe('mergeExtensionParameters', () => {
const pluginA = createPlugin({ id: 'test', extensions: [a] });
expect(
mergeExtensionParameters({
sources: [pluginA],
features: [pluginA],
builtinExtensions: [b],
parameters: [
{
@@ -88,7 +88,7 @@ describe('mergeExtensionParameters', () => {
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
expect(
mergeExtensionParameters({
sources: [plugin],
features: [plugin],
builtinExtensions: [],
parameters: [
{
@@ -126,7 +126,7 @@ describe('mergeExtensionParameters', () => {
const b = makeExt('b', 'disabled');
expect(
mergeExtensionParameters({
sources: [createPlugin({ id: 'empty', extensions: [] })],
features: [createPlugin({ id: 'empty', extensions: [] })],
builtinExtensions: [a, b],
parameters: [
{
@@ -15,7 +15,13 @@
*/
import { Config } from '@backstage/config';
import { BackstagePlugin, Extension } from '@backstage/frontend-plugin-api';
import {
BackstagePlugin,
Extension,
ExtensionOverrides,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { JsonValue } from '@backstage/types';
export interface ExtensionParameters {
@@ -208,15 +214,26 @@ export interface ExtensionInstanceParameters {
/** @internal */
export function mergeExtensionParameters(options: {
sources: BackstagePlugin[];
features: (BackstagePlugin | ExtensionOverrides)[];
builtinExtensions: Extension<unknown>[];
parameters: Array<ExtensionParameters>;
}): ExtensionInstanceParameters[] {
const { sources, builtinExtensions, parameters } = options;
const { builtinExtensions, parameters } = options;
const pluginExtensions = sources.flatMap(source => {
const plugins = options.features.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
);
const overrides = options.features.filter(
(f): f is ExtensionOverrides =>
f.$$type === '@backstage/ExtensionOverrides',
);
const pluginExtensions = plugins.flatMap(source => {
return source.extensions.map(extension => ({ ...extension, source }));
});
const overrideExtensions = overrides.flatMap(
override => toInternalExtensionOverrides(override).extensions,
);
// Prevent root override
if (pluginExtensions.some(({ id }) => id === 'root')) {
@@ -230,7 +247,28 @@ export function mergeExtensionParameters(options: {
);
}
const overrides = [
if (overrideExtensions.some(({ id }) => id === 'root')) {
throw new Error(
`An extension override is overriding the 'root' extension which is forbidden`,
);
}
const overrideExtensionIds = overrideExtensions.map(({ id }) => id);
if (overrideExtensionIds.length !== new Set(overrideExtensionIds).size) {
const counts = new Map<string, number>();
for (const id of overrideExtensionIds) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
const duplicated = Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([id]) => id);
throw new Error(
`The following extensions had duplicate overrides: ${duplicated.join(
', ',
)}`,
);
}
const configuredExtensions = [
...pluginExtensions.map(({ source, ...extension }) => ({
extension,
params: {
@@ -251,8 +289,33 @@ export function mergeExtensionParameters(options: {
})),
];
// Install all extension overrides
for (const extension of overrideExtensions) {
// Check if our override is overriding an extension that already exists
const index = configuredExtensions.findIndex(
e => e.extension.id === extension.id,
);
if (index !== -1) {
// Only implementation, attachment point and default disabled status are overridden, the source is kept
configuredExtensions[index].extension = extension;
configuredExtensions[index].params.attachTo = extension.attachTo;
configuredExtensions[index].params.disabled = extension.disabled;
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
extension,
params: {
source: undefined,
attachTo: extension.attachTo,
disabled: extension.disabled,
config: undefined,
},
});
}
}
const duplicatedExtensionIds = new Set<string>();
const duplicatedExtensionData = overrides.reduce<
const duplicatedExtensionData = configuredExtensions.reduce<
Record<string, Record<string, number>>
>((data, { extension, params }) => {
const extensionId = extension.id;
@@ -296,11 +359,11 @@ export function mergeExtensionParameters(options: {
);
}
const existingIndex = overrides.findIndex(
const existingIndex = configuredExtensions.findIndex(
e => e.extension.id === extensionId,
);
if (existingIndex !== -1) {
const existing = overrides[existingIndex];
const existing = configuredExtensions[existingIndex];
if (overrideParam.attachTo) {
existing.params.attachTo = overrideParam.attachTo;
}
@@ -314,8 +377,8 @@ export function mergeExtensionParameters(options: {
existing.params.disabled = Boolean(overrideParam.disabled);
if (!existing.params.disabled) {
// bump
overrides.splice(existingIndex, 1);
overrides.push(existing);
configuredExtensions.splice(existingIndex, 1);
configuredExtensions.push(existing);
}
}
} else {
@@ -323,7 +386,7 @@ export function mergeExtensionParameters(options: {
}
}
return overrides
return configuredExtensions
.filter(override => !override.params.disabled)
.map(param => ({
extension: param.extension,
@@ -104,14 +104,14 @@ const outputExtension = createExtension({
});
function createTestAppRoot({
plugins,
features,
config = {},
}: {
plugins: BackstagePlugin[];
features: BackstagePlugin[];
config: JsonObject;
}) {
return createApp({
plugins: plugins,
features,
configLoader: async () => new MockConfigApi(config),
}).createRoot();
}
@@ -132,7 +132,7 @@ describe('createPlugin', () => {
await renderWithEffects(
createTestAppRoot({
plugins: [plugin],
features: [plugin],
config: { app: { extensions: [{ 'core.layout': false }] } },
}),
);
@@ -157,7 +157,7 @@ describe('createPlugin', () => {
await renderWithEffects(
createTestAppRoot({
plugins: [plugin],
features: [plugin],
config: {
app: {
extensions: [
+1 -1
View File
@@ -168,7 +168,7 @@ describe('createSearchResultListItemExtension', () => {
});
const app = createApp({
plugins: [SearchPlugin],
features: [SearchPlugin],
configLoader: async () =>
new MockConfigApi({
app: {