From e23f5e07626794d1caf393d655aec19072637b1a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 10 Mar 2025 09:53:14 +0100 Subject: [PATCH] frontend-app-api: support array of extension factory middlewares Signed-off-by: Patrik Oldsberg --- .changeset/sixty-ducks-bathe.md | 5 ++ packages/frontend-app-api/report.api.md | 11 ++- .../src/tree/instantiateAppNodeTree.ts | 26 +++---- .../src/wiring/createSpecializedApp.test.tsx | 68 +++++++++++++++++++ .../src/wiring/createSpecializedApp.tsx | 61 ++++++++++++++--- packages/frontend-app-api/src/wiring/types.ts | 7 +- packages/frontend-defaults/report.api.md | 6 +- packages/frontend-defaults/src/createApp.tsx | 11 ++- packages/frontend-plugin-api/report.api.md | 12 ++++ .../frontend-plugin-api/src/wiring/index.ts | 1 + .../frontend-plugin-api/src/wiring/types.ts | 14 ++++ 11 files changed, 180 insertions(+), 42 deletions(-) create mode 100644 .changeset/sixty-ducks-bathe.md diff --git a/.changeset/sixty-ducks-bathe.md b/.changeset/sixty-ducks-bathe.md new file mode 100644 index 0000000000..c97f3b34dd --- /dev/null +++ b/.changeset/sixty-ducks-bathe.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added new `ExtensionMiddlewareFactory` type. diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index 74cf2bfb8a..d3c2045f85 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -6,7 +6,7 @@ import { ApiHolder } from '@backstage/core-plugin-api'; import { AppTree } from '@backstage/frontend-plugin-api'; import { ConfigApi } from '@backstage/core-plugin-api'; -import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendModule } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -32,17 +32,14 @@ export function createSpecializedApp(options?: { config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; apis?: ApiHolder; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; }): { apis: ApiHolder; tree: AppTree; }; -// @public (undocumented) -export type ExtensionFactoryMiddleware = Parameters< - ExtensionDefinition['override'] ->[0]['factory']; - // @public (undocumented) export type FrontendFeature = | FrontendPlugin diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 21c61ba1b2..c6a2bae491 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -19,6 +19,7 @@ import { ApiHolder, ExtensionDataContainer, ExtensionDataRef, + ExtensionFactoryMiddleware, ExtensionInput, ResolvedExtensionInputs, } from '@backstage/frontend-plugin-api'; @@ -26,7 +27,6 @@ import mapValues from 'lodash/mapValues'; import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; -import { ExtensionFactoryMiddleware } from '../wiring'; import { createExtensionDataContainer } from '@internal/frontend'; type Mutable = { @@ -302,20 +302,16 @@ export function createAppNodeInstance(options: { }; const outputDataValues = options.extensionFactoryMiddleware ? createExtensionDataContainer( - options.extensionFactoryMiddleware( - ({ config: configOverride } = {}) => - createExtensionDataContainer( - internalExtension.factory( - configOverride - ? { - ...context, - config: configOverride, - } - : context, - ), - ), - context, - ), + options.extensionFactoryMiddleware(overrideContext => { + return createExtensionDataContainer( + internalExtension.factory({ + node: context.node, + apis: context.apis, + inputs: context.inputs, + config: overrideContext?.config ?? context.config, + }), + ); + }, context), ) : internalExtension.factory(context); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 7fed91add8..3b1f6eb25c 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -26,6 +26,7 @@ import { createExtensionInput, useRouteRef, analyticsApiRef, + createExtensionDataRef, } from '@backstage/frontend-plugin-api'; import { screen, render } from '@testing-library/react'; import { createSpecializedApp } from './createSpecializedApp'; @@ -603,4 +604,71 @@ describe('createSpecializedApp', () => { " `); }); + + it('should apply multiple middlewares in order', () => { + const textDataRef = createExtensionDataRef().with({ id: 'text' }); + + const app = createSpecializedApp({ + features: [ + createFrontendPlugin({ + id: 'test', + extensions: [ + createExtension({ + attachTo: { id: 'root', input: 'app' }, + inputs: { + text: createExtensionInput([textDataRef], { + singleton: true, + }), + }, + output: [coreExtensionData.reactElement], + factory: ({ inputs }) => [ + coreExtensionData.reactElement( + <>{inputs.text.get(textDataRef)}, + ), + ], + }), + createExtension({ + name: 'child', + attachTo: { id: 'test', input: 'text' }, + config: { + schema: { + text: z => z.string().default('test'), + }, + }, + output: [textDataRef], + factory: ({ config }) => [textDataRef(config.text)], + }), + ], + }), + ], + extensionFactoryMiddleware: [ + function* middleware(originalFactory, { config }) { + const result = originalFactory({ + config: config && { text: `1-${config.text}` }, + }); + yield* result; + const el = result.get(textDataRef); + if (el) { + yield textDataRef(`${el}-1`); + } + }, + function* middleware(originalFactory, { config }) { + const result = originalFactory({ + config: config && { text: `2-${config.text}` }, + }); + yield* result; + const el = result.get(textDataRef); + if (el) { + yield textDataRef(`${el}-2`); + } + }, + ], + }); + + const root = app.tree.root.instance!.getData( + coreExtensionData.reactElement, + ); + + expect(render(root).container.textContent).toBe('1-2-test-1-2'); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 3379edcc08..21fde6547b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -30,6 +30,7 @@ import { createApiFactory, routeResolutionApiRef, AppNode, + ExtensionFactoryMiddleware, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -40,10 +41,16 @@ import { identityApiRef, } from '@backstage/core-plugin-api'; import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api'; -import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { + createExtensionDataContainer, + OpaqueFrontendPlugin, +} from '@internal/frontend'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; +import { + resolveExtensionDefinition, + toInternalExtension, +} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode'; @@ -67,11 +74,7 @@ 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 { - ExtensionFactoryMiddleware, - FrontendFeature, - RouteInfo, -} from './types'; +import { FrontendFeature, RouteInfo } from './types'; import { matchRoutes } from 'react-router-dom'; function deduplicateFeatures( @@ -202,7 +205,9 @@ export function createSpecializedApp(options?: { config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; apis?: ApiHolder; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; }): { apis: ApiHolder; tree: AppTree } { const config = options?.config ?? new ConfigReader({}, 'empty-config'); const features = deduplicateFeatures(options?.features ?? []); @@ -267,7 +272,11 @@ export function createSpecializedApp(options?: { } // Now instantiate the entire tree, which will skip anything that's already been instantiated - instantiateAppNodeTree(tree.root, apis, options?.extensionFactoryMiddleware); + instantiateAppNodeTree( + tree.root, + apis, + mergeExtensionFactoryMiddleware(options?.extensionFactoryMiddleware), + ); const routeInfo = extractRouteInfoFromAppNode(tree.root); @@ -313,3 +322,37 @@ function createApiHolder(options: { 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, + }), + ); + }, ctx); + }; + }); +} diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts index d7eca83e0a..18d9c51d0e 100644 --- a/packages/frontend-app-api/src/wiring/types.ts +++ b/packages/frontend-app-api/src/wiring/types.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ExtensionDefinition, RouteRef } from '@backstage/frontend-plugin-api'; +import { RouteRef } from '@backstage/frontend-plugin-api'; import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from '../routing/types'; @@ -31,8 +31,3 @@ export type RouteInfo = { routeParents: Map; routeObjects: BackstageRouteObject[]; }; - -/** @public */ -export type ExtensionFactoryMiddleware = Parameters< - ExtensionDefinition['override'] ->[0]['factory']; diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 1c0ee7e6a7..5936bcd3b6 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -5,7 +5,7 @@ ```ts 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-app-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; @@ -33,7 +33,9 @@ export interface CreateAppOptions { config: ConfigApi; }>; // (undocumented) - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; // (undocumented) features?: (FrontendFeature | CreateAppFeatureLoader)[]; loadingComponent?: ReactNode; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index a8b40a5f63..abf2151434 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -15,7 +15,11 @@ */ import React, { JSX, ReactNode } from 'react'; -import { ConfigApi, coreExtensionData } from '@backstage/frontend-plugin-api'; +import { + ConfigApi, + coreExtensionData, + ExtensionFactoryMiddleware, +} from '@backstage/frontend-plugin-api'; import { stringifyError } from '@backstage/errors'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; @@ -26,7 +30,6 @@ import { ConfigReader } from '@backstage/config'; import appPlugin from '@backstage/plugin-app'; import { CreateAppRouteBinder, - ExtensionFactoryMiddleware, FrontendFeature, createSpecializedApp, } from '@backstage/frontend-app-api'; @@ -66,7 +69,9 @@ export interface CreateAppOptions { * If set to "null" then no loading fallback component is rendered. * */ loadingComponent?: ReactNode; - extensionFactoryMiddleware?: ExtensionFactoryMiddleware; + extensionFactoryMiddleware?: + | ExtensionFactoryMiddleware + | ExtensionFactoryMiddleware[]; } /** diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 0acb2c4bb8..5f5f0dc842 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1175,6 +1175,18 @@ export type ExtensionDefinitionParameters = { params?: object; }; +// @public (undocumented) +export type ExtensionFactoryMiddleware = ( + originalFactory: (contextOverrides?: { + config?: JsonObject; + }) => ExtensionDataContainer, + context: { + node: AppNode; + apis: ApiHolder; + config?: JsonObject; + }, +) => Iterable>; + // @public (undocumented) export interface ExtensionInput< UExtensionData extends ExtensionDataRef< diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index e5aed3f743..6dec88b5f4 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -54,6 +54,7 @@ export { type ExtensionOverrides, type FeatureFlagConfig, type FrontendFeature, + type ExtensionFactoryMiddleware, } from './types'; export { type CreateExtensionBlueprintOptions, diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts index 884234ac74..3aba32d9fc 100644 --- a/packages/frontend-plugin-api/src/wiring/types.ts +++ b/packages/frontend-plugin-api/src/wiring/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { JsonObject } from '@backstage/types'; import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing'; import { ExtensionDefinition } from './createExtension'; import { @@ -22,6 +23,7 @@ import { ExtensionDataValue, } from './createExtensionDataRef'; import { FrontendPlugin } from './createFrontendPlugin'; +import { ApiHolder, AppNode } from '../apis'; /** * Feature flag configuration. @@ -78,3 +80,15 @@ export type ExtensionDataContainer = : IData : never; }; + +/** @public */ +export type ExtensionFactoryMiddleware = ( + originalFactory: (contextOverrides?: { + config?: JsonObject; + }) => ExtensionDataContainer, + context: { + node: AppNode; + apis: ApiHolder; + config?: JsonObject; + }, +) => Iterable>;