diff --git a/.changeset/fresh-timers-walk.md b/.changeset/fresh-timers-walk.md new file mode 100644 index 0000000000..a187bede83 --- /dev/null +++ b/.changeset/fresh-timers-walk.md @@ -0,0 +1,20 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Support overriding of plugin extensions using the new `plugin.withOverrides` method. + +```tsx +import homePlugin from '@backstage/plugin-home'; + +export default homePlugin.withOverrides({ + extensions: [ + homePage.getExtension('page:home').override({ + *factory(originalFactory) { + yield* originalFactory(); + yield coreExtensionData.reactElement(

My custom home page

); + }, + }), + ], +}); +``` diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 6d548e3c26..f841fc2ce2 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -356,6 +356,10 @@ export interface BackstagePlugin< readonly id: string; // (undocumented) readonly routes: TRoutes; + // (undocumented) + withOverrides(options: { + extensions: Array>; + }): BackstagePlugin; } export { BackstageUserIdentity }; diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts index 76cc030be3..f76e4ec9a5 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts @@ -148,7 +148,28 @@ describe('createPlugin', () => { }); expect(plugin).toBeDefined(); - expect(plugin.getExtension('test/1')).toBe(Extension1); + expect(plugin.getExtension('test/1')).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "attachTo": { + "id": "test/output", + "input": "names", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": undefined, + "name": "1", + "namespace": "test", + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); // @ts-expect-error expect(plugin.getExtension('nonexistent')).toBeUndefined(); @@ -250,4 +271,68 @@ describe('createPlugin', () => { }), ).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3"); }); + + describe('overrides', () => { + it('should return a plugin instance with the correct namespace', () => { + const plugin = createPlugin({ + id: 'test', + extensions: [Extension1, Extension2], + }); + + expect(plugin.getExtension('test/1')).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "attachTo": { + "id": "test/output", + "input": "names", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": undefined, + "name": "1", + "namespace": "test", + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); + }); + + it('should allow overriding extensions that have a matching ID, while keeping old extensions that do not have overlapping IDs', async () => { + const plugin = createPlugin({ + id: 'test', + extensions: [Extension1, Extension2, outputExtension], + }); + + await renderWithEffects( + createTestAppRoot({ + features: [ + plugin.withOverrides({ + extensions: [ + plugin.getExtension('test/1').override({ + factory() { + return [nameExtensionDataRef('overridden')]; + }, + }), + ], + }), + ], + config: { + app: { + extensions: [{ 'app/root': false }], + }, + }, + }), + ); + + await expect( + screen.findByText('Names: extension-2, overridden'), + ).resolves.toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.ts index 1d2a12ffe2..2f7a813c90 100644 --- a/packages/frontend-plugin-api/src/wiring/createPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createPlugin.ts @@ -78,7 +78,7 @@ export function createPlugin< for (const def of options.extensions ?? []) { const ext = resolveExtensionDefinition(def, { namespace: options.id }); extensions.push(ext); - extensionDefinitionsById.set(ext.id, def); + extensionDefinitionsById.set(ext.id, { ...def, namespace: options.id }); } if (extensions.length !== extensionDefinitionsById.size) { @@ -110,6 +110,23 @@ export function createPlugin< toString() { return `Plugin{id=${options.id}}`; }, + withOverrides(overrides) { + const overrideExtensionIds = new Set( + overrides.extensions.map( + e => resolveExtensionDefinition(e, { namespace: options.id }).id, + ), + ); + const nonOverridenExtensions = (options.extensions ?? []).filter( + e => + !overrideExtensionIds.has( + resolveExtensionDefinition(e, { namespace: options.id }).id, + ), + ); + return createPlugin({ + ...options, + extensions: [...nonOverridenExtensions, ...overrides.extensions], + }); + }, } as InternalBackstagePlugin; } diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts index b1afd4e7ea..4a7d972b37 100644 --- a/packages/frontend-plugin-api/src/wiring/types.ts +++ b/packages/frontend-plugin-api/src/wiring/types.ts @@ -51,6 +51,9 @@ export interface BackstagePlugin< readonly routes: TRoutes; readonly externalRoutes: TExternalRoutes; getExtension(id: TId): TExtensionMap[TId]; + withOverrides(options: { + extensions: Array>; + }): BackstagePlugin; } /** @public */