feat: introduced createFrontendModule and deprecating createExtensionOverrides

Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-08-27 15:43:13 +02:00
parent c1ef48f4fb
commit 860f6e7a3f
12 changed files with 307 additions and 64 deletions
@@ -31,7 +31,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { collectLegacyRoutes } from './collectLegacyRoutes';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalBackstagePlugin } from '../../frontend-plugin-api/src/wiring/createFrontendPlugin';
import { toInternalFrontendPlugin } from '../../frontend-plugin-api/src/wiring/createFrontendPlugin';
import {
createPlugin,
createRoutableExtension,
@@ -55,7 +55,7 @@ describe('collectLegacyRoutes', () => {
expect(
collected.map(p => ({
id: p.id,
extensions: toInternalBackstagePlugin(p).extensions.map(e => ({
extensions: toInternalFrontendPlugin(p).extensions.map(e => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
@@ -158,7 +158,7 @@ describe('collectLegacyRoutes', () => {
expect(
collected.map(p => ({
id: p.id,
extensions: toInternalBackstagePlugin(p).extensions.map(e => ({
extensions: toInternalFrontendPlugin(p).extensions.map(e => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
@@ -24,7 +24,7 @@ import {
import { convertLegacyPlugin } from './convertLegacyPlugin';
import { PageBlueprint } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalBackstagePlugin } from '../../frontend-plugin-api/src/wiring/createFrontendPlugin';
import { toInternalFrontendPlugin } from '../../frontend-plugin-api/src/wiring/createFrontendPlugin';
describe('convertLegacyPlugin', () => {
it('should convert a plain legacy plugin to a new plugin', () => {
@@ -34,12 +34,13 @@ describe('convertLegacyPlugin', () => {
}),
).toMatchInlineSnapshot(`
{
"$$type": "@backstage/BackstagePlugin",
"$$type": "@backstage/FrontendPlugin",
"extensions": [],
"externalRoutes": {},
"featureFlags": [],
"getExtension": [Function],
"id": "test",
"pluginId": "test",
"routes": {},
"toString": [Function],
"version": "v1",
@@ -73,7 +74,7 @@ describe('convertLegacyPlugin', () => {
},
);
const internalConverted = toInternalBackstagePlugin(converted);
const internalConverted = toInternalFrontendPlugin(converted);
expect(internalConverted.id).toBe('test');
expect(internalConverted.routes).toEqual({
@@ -29,6 +29,8 @@ import {
import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { isInternalFrontendPlugin } from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin';
/** @internal */
export interface RouteRefsById {
@@ -42,12 +44,12 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
const externalRoutesById = new Map<string, ExternalRouteRef>();
for (const feature of features) {
if (feature.$$type !== '@backstage/BackstagePlugin') {
if (!isInternalFrontendPlugin(feature)) {
continue;
}
for (const [name, ref] of Object.entries(feature.routes)) {
const refId = `${feature.id}.${name}`;
const refId = `${feature.pluginId}.${name}`;
if (routesById.has(refId)) {
throw new Error(`Unexpected duplicate route '${refId}'`);
}
@@ -62,7 +64,7 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
}
}
for (const [name, ref] of Object.entries(feature.externalRoutes)) {
const refId = `${feature.id}.${name}`;
const refId = `${feature.pluginId}.${name}`;
if (externalRoutesById.has(refId)) {
throw new Error(`Unexpected duplicate external route '${refId}'`);
}
@@ -15,7 +15,6 @@
*/
import {
BackstagePlugin,
Extension,
ExtensionOverrides,
FrontendFeature,
@@ -25,7 +24,10 @@ import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/w
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin';
import {
isInternalFrontendPlugin,
toInternalFrontendPlugin,
} from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
@@ -43,16 +45,14 @@ export function resolveAppNodeSpecs(options: {
features = [],
} = options;
const plugins = features.filter(
(f): f is BackstagePlugin => f.$$type === '@backstage/BackstagePlugin',
);
const plugins = features.filter(isInternalFrontendPlugin);
const overrides = features.filter(
(f): f is ExtensionOverrides =>
f.$$type === '@backstage/ExtensionOverrides',
);
const pluginExtensions = plugins.flatMap(source => {
return toInternalBackstagePlugin(source).extensions.map(extension => ({
return toInternalFrontendPlugin(source).extensions.map(extension => ({
...extension,
source,
}));
@@ -65,7 +65,7 @@ export function resolveAppNodeSpecs(options: {
if (pluginExtensions.some(({ id }) => forbidden.has(id))) {
const pluginsStr = pluginExtensions
.filter(({ id }) => forbidden.has(id))
.map(({ source }) => `'${source.id}'`)
.map(({ source }) => `'${source.pluginId}'`)
.join(', ');
const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
throw new Error(
@@ -156,7 +156,7 @@ export function resolveAppNodeSpecs(options: {
const extensionId = extension.id;
const extensionData = data?.[extensionId];
if (extensionData) duplicatedExtensionIds.add(extensionId);
const pluginId = params.source?.id ?? 'internal';
const pluginId = params.source?.pluginId ?? 'internal';
const pluginCount = extensionData?.[pluginId] ?? 0;
return {
...data,
@@ -23,6 +23,7 @@ import {
PageBlueprint,
createFrontendPlugin,
ThemeBlueprint,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { screen, waitFor } from '@testing-library/react';
import { CreateAppFeatureLoader, createApp } from './createApp';
@@ -379,4 +380,57 @@ describe('createApp', () => {
expect(screen.queryByText('Custom app root element')).toBeNull();
});
describe('modules', () => {
it('should be able to override extensions with a plugin extension override', async () => {
const mod = createFrontendModule({
pluginId: 'app',
extensions: [
appPlugin.getExtension('app/root').override({
factory: () => [
coreExtensionData.reactElement(
<div>Custom app root element</div>,
),
],
}),
],
});
const app = createApp({
configLoader: () => new Promise(() => {}),
features: [mod],
});
await renderWithEffects(app.createRoot());
expect(screen.queryByText('Custom app root element')).toBeNull();
});
it('should be able to override extensions with a standalone extension override', async () => {
const mod = createFrontendModule({
pluginId: 'app',
extensions: [
createExtension({
name: 'root',
attachTo: { id: 'app', input: 'root' },
output: [coreExtensionData.reactElement],
factory: () => [
coreExtensionData.reactElement(
<div>Custom app root element</div>,
),
],
}),
],
});
const app = createApp({
configLoader: () => new Promise(() => {}),
features: [mod],
});
await renderWithEffects(app.createRoot());
expect(screen.queryByText('Custom app root element')).toBeNull();
});
});
});
@@ -61,7 +61,15 @@ 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 { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin';
import {
toInternalFrontendPlugin,
isInternalFrontendPlugin,
} from '../../../frontend-plugin-api/src/wiring/createFrontendPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
toInternalFrontendModule,
isInternalFrontendModule,
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { stringifyError } from '@backstage/errors';
@@ -89,13 +97,13 @@ function deduplicateFeatures(
return features
.reverse()
.filter(feature => {
if (feature.$$type !== '@backstage/BackstagePlugin') {
if (!isInternalFrontendPlugin(feature)) {
return true;
}
if (seenIds.has(feature.id)) {
if (seenIds.has(feature.pluginId)) {
return false;
}
seenIds.add(feature.id);
seenIds.add(feature.pluginId);
return true;
})
.reverse();
@@ -318,11 +326,19 @@ export function createSpecializedApp(options?: {
const featureFlagApi = apiHolder.get(featureFlagsApiRef);
if (featureFlagApi) {
for (const feature of features) {
if (feature.$$type === '@backstage/BackstagePlugin') {
toInternalBackstagePlugin(feature).featureFlags.forEach(flag =>
if (isInternalFrontendPlugin(feature)) {
toInternalFrontendPlugin(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
pluginId: feature.id,
pluginId: feature.pluginId,
}),
);
}
if (isInternalFrontendModule(feature)) {
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
pluginId: feature.pluginId,
}),
);
}
@@ -21,7 +21,10 @@ import {
} from './resolveExtensionDefinition';
import { ExtensionOverrides, FeatureFlagConfig } from './types';
/** @public */
/**
* @deprecated Use {@link createFrontendModule} instead.
* @public
*/
export interface ExtensionOverridesOptions {
extensions: ExtensionDefinition[];
featureFlags?: FeatureFlagConfig[];
@@ -34,7 +37,10 @@ export interface InternalExtensionOverrides extends ExtensionOverrides {
readonly featureFlags: FeatureFlagConfig[];
}
/** @public */
/**
* @deprecated Use {@link createFrontendModule} instead.
* @public
*/
export function createExtensionOverrides(
options: ExtensionOverridesOptions,
): ExtensionOverrides {
@@ -0,0 +1,127 @@
/*
* 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 {
ExtensionDefinition,
InternalExtensionDefinition,
toInternalExtensionDefinition,
} from './createExtension';
import {
Extension,
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
/** @public */
export interface ModuleOptions<
TPluginId extends string,
TExtensions extends readonly ExtensionDefinition[],
> {
pluginId: TPluginId;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
}
/** @public */
export interface FrontendModule {
readonly $$type: '@backstage/FrontendModule';
readonly pluginId: string;
}
/** @internal */
export interface InternalFrontendModule extends FrontendModule {
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
}
/** @public */
export function createFrontendModule<
TId extends string,
TExtensions extends readonly ExtensionDefinition[] = [],
>(options: ModuleOptions<TId, TExtensions>): FrontendModule {
const { pluginId } = options;
const extensions = new Array<Extension<any>>();
const extensionDefinitionsById = new Map<
string,
InternalExtensionDefinition
>();
for (const def of options.extensions ?? []) {
const internal = toInternalExtensionDefinition(def);
const ext = resolveExtensionDefinition(def, { namespace: pluginId });
extensions.push(ext);
extensionDefinitionsById.set(ext.id, {
...internal,
namespace: pluginId,
});
}
if (extensions.length !== extensionDefinitionsById.size) {
const extensionIds = extensions.map(e => e.id);
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
),
);
// TODO(Rugvip): This could provide some more information about the kind + name of the extensions
throw new Error(
`Plugin '${pluginId}' provided duplicate extensions: ${duplicates.join(
', ',
)}`,
);
}
return {
$$type: '@backstage/FrontendModule',
version: 'v1',
pluginId,
featureFlags: options.featureFlags ?? [],
extensions,
toString() {
return `Module{pluginId=${pluginId}}`;
},
} as InternalFrontendModule;
}
/** @internal */
export function isInternalFrontendModule(opaque: {
$$type: string;
}): opaque is InternalFrontendModule {
if (opaque.$$type === '@backstage/FrontendModule') {
// Make sure we throw if invalid
toInternalFrontendModule(opaque as FrontendModule);
return true;
}
return false;
}
/** @internal */
export function toInternalFrontendModule(
plugin: FrontendModule,
): InternalFrontendModule {
const internal = plugin as InternalFrontendModule;
if (internal.$$type !== '@backstage/FrontendModule') {
throw new Error(`Invalid plugin instance, bad type '${internal.$$type}'`);
}
if (internal.version !== 'v1') {
throw new Error(
`Invalid plugin instance, bad version '${internal.version}'`,
);
}
return internal;
}
@@ -17,14 +17,13 @@
import React from 'react';
import { createApp } from '@backstage/frontend-app-api';
import { screen } from '@testing-library/react';
import { createFrontendPlugin } from './createFrontendPlugin';
import { FrontendPlugin, createFrontendPlugin } from './createFrontendPlugin';
import { JsonObject } from '@backstage/types';
import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { coreExtensionData } from './coreExtensionData';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import { createExtensionInput } from './createExtensionInput';
import { BackstagePlugin } from './types';
const nameExtensionDataRef = createExtensionDataRef<string>().with({
id: 'name',
@@ -123,7 +122,7 @@ function createTestAppRoot({
features,
config = {},
}: {
features: BackstagePlugin[];
features: FrontendPlugin[];
config: JsonObject;
}) {
return createApp({
@@ -24,12 +24,33 @@ import {
ResolveExtensionId,
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import {
AnyExternalRoutes,
AnyRoutes,
BackstagePlugin,
FeatureFlagConfig,
} from './types';
import { AnyExternalRoutes, AnyRoutes, FeatureFlagConfig } from './types';
/** @public */
export interface FrontendPlugin<
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
TExtensionMap extends { [id in string]: ExtensionDefinition } = {},
> {
readonly $$type: '@backstage/FrontendPlugin';
readonly pluginId: string;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
}): FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
/**
* @public
* @deprecated Use {@link FrontendPlugin} instead.
*/
export type BackstagePlugin<
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
TExtensionMap extends { [id in string]: ExtensionDefinition } = {},
> = FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
/** @public */
export interface PluginOptions<
@@ -48,10 +69,10 @@ export interface PluginOptions<
}
/** @public */
export interface InternalBackstagePlugin<
export interface InternalFrontendPlugin<
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
> extends BackstagePlugin<TRoutes, TExternalRoutes> {
> extends FrontendPlugin<TRoutes, TExternalRoutes> {
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
@@ -65,7 +86,7 @@ export function createFrontendPlugin<
TExtensions extends readonly ExtensionDefinition[] = [],
>(
options: PluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>,
): BackstagePlugin<
): FrontendPlugin<
TRoutes,
TExternalRoutes,
{
@@ -112,9 +133,9 @@ export function createFrontendPlugin<
}
return {
$$type: '@backstage/BackstagePlugin',
$$type: '@backstage/FrontendPlugin',
version: 'v1',
id: pluginId,
id: pluginId, // TODO: Backwards compat to be able to install in older apps, remove after 1.31
pluginId,
routes: options.routes ?? ({} as TRoutes),
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
@@ -143,15 +164,33 @@ export function createFrontendPlugin<
extensions: [...nonOverriddenExtensions, ...overrides.extensions],
});
},
} as InternalBackstagePlugin<TRoutes, TExternalRoutes>;
} as InternalFrontendPlugin<TRoutes, TExternalRoutes>;
}
/** @internal */
export function toInternalBackstagePlugin(
plugin: BackstagePlugin,
): InternalBackstagePlugin {
const internal = plugin as InternalBackstagePlugin;
if (internal.$$type !== '@backstage/BackstagePlugin') {
export function isInternalFrontendPlugin(opaque: {
$$type: string;
}): opaque is InternalFrontendPlugin {
if (
opaque.$$type === '@backstage/FrontendPlugin' ||
opaque.$$type === '@backstage/BackstagePlugin'
) {
// Throw if invalid + mutate
toInternalFrontendPlugin(opaque as FrontendPlugin);
return true;
}
return false;
}
/** @internal */
export function toInternalFrontendPlugin(
plugin: FrontendPlugin,
): InternalFrontendPlugin {
const internal = plugin as InternalFrontendPlugin;
if (
internal.$$type !== '@backstage/FrontendPlugin' &&
internal.$$type !== '@backstage/BackstagePlugin'
) {
throw new Error(`Invalid plugin instance, bad type '${internal.$$type}'`);
}
if (internal.version !== 'v1') {
@@ -159,6 +198,10 @@ export function toInternalBackstagePlugin(
`Invalid plugin instance, bad version '${internal.version}'`,
);
}
// Backwards compatibility to support old plugins defined with just an .id
if (!internal.pluginId) {
(internal as any).pluginId = (internal as any).id;
}
return internal;
}
@@ -39,8 +39,15 @@ export {
export {
createPlugin,
createFrontendPlugin,
type FrontendPlugin,
type BackstagePlugin,
type PluginOptions,
} from './createFrontendPlugin';
export {
createFrontendModule,
type FrontendModule,
type ModuleOptions,
} from './createFrontendModule';
export {
createExtensionOverrides,
type ExtensionOverridesOptions,
@@ -49,7 +56,6 @@ export { type Extension } from './resolveExtensionDefinition';
export {
type AnyRoutes,
type AnyExternalRoutes,
type BackstagePlugin,
type ExtensionOverrides,
type FeatureFlagConfig,
type FrontendFeature,
@@ -16,6 +16,8 @@
import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing';
import { ExtensionDefinition } from './createExtension';
import { FrontendModule } from './createFrontendModule';
import { FrontendPlugin } from './createFrontendPlugin';
/**
* Feature flag configuration.
@@ -40,26 +42,13 @@ export type ExtensionMap<
get<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
};
/** @public */
export interface BackstagePlugin<
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
TExtensionMap extends { [id in string]: ExtensionDefinition } = {},
> {
readonly $$type: '@backstage/BackstagePlugin';
readonly id: string;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
}): BackstagePlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
/** @public */
export interface ExtensionOverrides {
readonly $$type: '@backstage/ExtensionOverrides';
}
/** @public */
export type FrontendFeature = BackstagePlugin | ExtensionOverrides;
export type FrontendFeature =
| FrontendPlugin
| FrontendModule
| ExtensionOverrides;