diff --git a/.changeset/thick-squids-drive.md b/.changeset/thick-squids-drive.md new file mode 100644 index 0000000000..0e798063c0 --- /dev/null +++ b/.changeset/thick-squids-drive.md @@ -0,0 +1,34 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-test-utils': patch +--- + +Added support for being able to override extension definitions. + +```tsx +const TestCard = EntityCardBlueprint.make({ + ... +}); + +TestCard.override({ + // override attachment points + attachTo: { id: 'something-else', input: 'overridden' }, + // extend the config schema + config: { + schema: { + newConfig: z => z.string().optional(), + } + }, + // override factory + *factory(originalFactory, { inputs, config }){ + const originalOutput = originalFactory(); + + yield coreExentsionData.reactElement( + + {originalOutput.get(coreExentsionData.reactElement)} + + ); + } +}); + +``` diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index de8889daf0..aded5d7826 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -68,7 +68,7 @@ function makeNode( function makeInstanceWithId( extension: Extension, - config?: TConfig, + config?: TConfigInput, ): AppNode { const node = makeNode(extension, { config }); return { @@ -640,12 +640,12 @@ describe('instantiateAppNodeTree', () => { name: 'test', attachTo: { id: 'ignored', input: 'ignored' }, output: [testDataRef, otherDataRef.optional()], - configSchema: createSchemaFromZod(z => - z.object({ - output: z.string().default('test'), - other: z.number().optional(), - }), - ), + config: { + schema: { + output: z => z.string().default('test'), + other: z => z.number().optional(), + }, + }, factory({ config }) { return [ testDataRef(config.output), diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 8af2aef070..65f3f77640 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -48,6 +48,7 @@ function makeExtDef( name, attachTo: { id: attachId, input: 'default' }, disabled: status === 'disabled', + override: () => ({} as ExtensionDefinition), } as ExtensionDefinition; } diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 4dc9c99c78..325dbd193e 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -396,7 +396,7 @@ export function createApiExtension< configSchema?: PortableSchema; inputs?: TInputs; }, -): ExtensionDefinition; +): ExtensionDefinition; // @public (undocumented) export namespace createApiExtension { @@ -492,7 +492,7 @@ export function createComponentExtension< inputs: Expand>; }) => ComponentType; }; -}): ExtensionDefinition; +}): ExtensionDefinition; // @public (undocumented) export namespace createComponentExtension { @@ -524,8 +524,6 @@ export function createExtension< } >; }, - TConfig, - TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType; }, @@ -534,26 +532,20 @@ export function createExtension< options: CreateExtensionOptions< UOutput, TInputs, - TConfig, - TConfigInput, TConfigSchema, UFactoryOutput >, ): ExtensionDefinition< - TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer>; - }), - TConfigInput & - (string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >) + { + [key in keyof TConfigSchema]: z.infer>; + }, + z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >, + UOutput, + TInputs >; // @public @deprecated (undocumented) @@ -562,33 +554,14 @@ export function createExtension< TInputs extends AnyExtensionInputMap, TConfig, TConfigInput, - TConfigSchema extends { - [key: string]: (zImpl: typeof z) => z.ZodType; - }, >( options: LegacyCreateExtensionOptions< TOutput, TInputs, TConfig, - TConfigInput, - TConfigSchema + TConfigInput >, -): ExtensionDefinition< - TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer>; - }), - TConfigInput & - (string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >) ->; +): ExtensionDefinition; // @public export function createExtensionBlueprint< @@ -751,8 +724,6 @@ export type CreateExtensionOptions< } >; }, - TConfig, - TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType; }, @@ -768,20 +739,14 @@ export type CreateExtensionOptions< disabled?: boolean; inputs?: TInputs; output: Array; - configSchema?: PortableSchema; config?: { schema: TConfigSchema; }; factory(context: { node: AppNode; - config: TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer< - ReturnType - >; - }); + config: { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }): Iterable; } & VerifyExtensionFactoryOutput; @@ -827,7 +792,9 @@ export function createNavItemExtension(options: { }, { title?: string | undefined; - } + }, + never, + never >; // @public (undocumented) @@ -850,7 +817,7 @@ export function createNavLogoExtension(options: { namespace?: string; logoIcon: JSX.Element; logoFull: JSX.Element; -}): ExtensionDefinition<{}, {}>; +}): ExtensionDefinition; // @public (undocumented) export namespace createNavLogoExtension { @@ -1005,7 +972,7 @@ export function createSubRouteRef< // @public (undocumented) export function createThemeExtension( theme: AppTheme, -): ExtensionDefinition<{}, {}>; +): ExtensionDefinition; // @public (undocumented) export namespace createThemeExtension { @@ -1021,7 +988,7 @@ export namespace createThemeExtension { export function createTranslationExtension(options: { name?: string; resource: TranslationResource | TranslationMessages; -}): ExtensionDefinition<{}, {}>; +}): ExtensionDefinition; // @public (undocumented) export namespace createTranslationExtension { @@ -1259,7 +1226,20 @@ export type ExtensionDataValues = { }; // @public (undocumented) -export interface ExtensionDefinition { +export interface ExtensionDefinition< + TConfig, + TConfigInput = TConfig, + UOutput extends AnyExtensionDataRef = AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + } = {}, +> { // (undocumented) $$type: '@backstage/ExtensionDefinition'; // (undocumented) @@ -1277,6 +1257,76 @@ export interface ExtensionDefinition { readonly name?: string; // (undocumented) readonly namespace?: string; + // (undocumented) + override< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends AnyExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { + optional: boolean; + singleton: boolean; + } + >; + }, + >( + args: { + attachTo?: { + id: string; + input: string; + }; + disabled?: boolean; + inputs?: TExtraInputs & { + [KName in keyof TInputs]?: `Error: Input '${KName & + string}' is already defined in parent definition`; + }; + output?: Array; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof TConfig]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; + factory( + originalFactory: (context?: { + config?: TConfig; + inputs?: Expand>; + }) => ExtensionDataContainer, + context: { + node: AppNode; + config: TConfig & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & VerifyExtensionFactoryOutput< + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, + UFactoryOutput + >, + ): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & TConfig, + z.input< + z.ZodObject<{ + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + }> + > & + TConfigInput, + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, + TInputs & TExtraInputs + >; } // @public (undocumented) @@ -1418,9 +1468,6 @@ export interface LegacyCreateExtensionOptions< TInputs extends AnyExtensionInputMap, TConfig, TConfigInput, - TConfigSchema extends { - [key: string]: (zImpl: typeof z) => z.ZodType; - }, > { // (undocumented) attachTo: { @@ -1428,24 +1475,13 @@ export interface LegacyCreateExtensionOptions< input: string; }; // (undocumented) - config?: { - schema: TConfigSchema; - }; - // @deprecated (undocumented) configSchema?: PortableSchema; // (undocumented) disabled?: boolean; // (undocumented) factory(context: { node: AppNode; - config: TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer< - ReturnType - >; - }); + config: TConfig; inputs: Expand>; }): Expand>; // (undocumented) diff --git a/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts b/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts index c4d0239094..8f8a0cd9ac 100644 --- a/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts +++ b/packages/frontend-plugin-api/src/extensions/createApiExtension.test.ts @@ -48,6 +48,7 @@ describe('createApiExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); }); @@ -85,6 +86,7 @@ describe('createApiExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); }); }); diff --git a/packages/frontend-plugin-api/src/extensions/createAppRootElementExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createAppRootElementExtension.test.tsx index c8bf6ee355..6e80f6fe13 100644 --- a/packages/frontend-plugin-api/src/extensions/createAppRootElementExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createAppRootElementExtension.test.tsx @@ -41,6 +41,7 @@ describe('createAppRootElementExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); createExtensionTester(extension).render(); @@ -88,6 +89,7 @@ describe('createAppRootElementExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); createExtensionTester(extension, { config: { name: 'Robin' } }) diff --git a/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.test.tsx index d21cfa7583..993c4fdee3 100644 --- a/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createAppRootWrapperExtension.test.tsx @@ -42,6 +42,7 @@ describe('createAppRootWrapperExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); createExtensionTester( @@ -94,6 +95,7 @@ describe('createAppRootWrapperExtension', () => { component: expect.anything(), }, factory: expect.any(Function), + override: expect.any(Function), toString: expect.any(Function), }); diff --git a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx index 8c54743f1d..17187a339d 100644 --- a/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createNavLogoExtension.test.tsx @@ -41,6 +41,7 @@ describe('createNavLogoExtension', () => { logos: expect.anything(), }, factory: expect.any(Function), + override: expect.any(Function), toString: expect.any(Function), }); }); diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx index 374400fce8..e5dd4027c0 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx @@ -56,6 +56,7 @@ describe('createPageExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); expect( @@ -78,6 +79,7 @@ describe('createPageExtension', () => { kind: 'page', attachTo: { id: 'other', input: 'place' }, configSchema: expect.anything(), + override: expect.any(Function), disabled: true, inputs: { first: createExtensionInput({ @@ -115,6 +117,7 @@ describe('createPageExtension', () => { }, factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), }); }); diff --git a/packages/frontend-plugin-api/src/extensions/createRouterExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createRouterExtension.test.tsx index 7109e1a8aa..2c16076fcf 100644 --- a/packages/frontend-plugin-api/src/extensions/createRouterExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createRouterExtension.test.tsx @@ -50,6 +50,7 @@ describe('createRouterExtension', () => { component: expect.anything(), }, factory: expect.any(Function), + override: expect.any(Function), toString: expect.any(Function), }); @@ -119,6 +120,7 @@ describe('createRouterExtension', () => { component: expect.anything(), }, factory: expect.any(Function), + override: expect.any(Function), toString: expect.any(Function), }); diff --git a/packages/frontend-plugin-api/src/extensions/createTranslationExtension.test.ts b/packages/frontend-plugin-api/src/extensions/createTranslationExtension.test.ts index 975901b8e5..019c565cbd 100644 --- a/packages/frontend-plugin-api/src/extensions/createTranslationExtension.test.ts +++ b/packages/frontend-plugin-api/src/extensions/createTranslationExtension.test.ts @@ -46,6 +46,7 @@ describe('createTranslationExtension', () => { namespace: 'test', attachTo: { id: 'app', input: 'translations' }, disabled: false, + override: expect.any(Function), inputs: {}, output: { resource: createTranslationExtension.translationDataRef, @@ -84,6 +85,7 @@ describe('createTranslationExtension', () => { namespace: 'test', attachTo: { id: 'app', input: 'translations' }, disabled: false, + override: expect.any(Function), inputs: {}, output: { resource: createTranslationExtension.translationDataRef, @@ -120,6 +122,7 @@ describe('createTranslationExtension', () => { version: 'v1', kind: 'translation', namespace: 'test', + override: expect.any(Function), name: 'sv', attachTo: { id: 'app', input: 'translations' }, disabled: false, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index ab6a6c19a5..f9e39e98de 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { createExtensionTester } from '@backstage/frontend-test-utils'; import { createExtension } from './createExtension'; import { createExtensionDataRef } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; @@ -304,9 +305,7 @@ describe('createExtension', () => { baz: z => z.string().optional(), }, }, - output: { - foo: stringDataRef, - }, + output: [stringDataRef], factory({ config }) { const a1: string = config.foo; const a2: string = config.bar; @@ -320,12 +319,10 @@ describe('createExtension', () => { const c3: number = config.baz; unused(a1, a2, a3, c1, c2, c3); - return { - foo: 'bar', - }; + return [stringDataRef('bar')]; }, }); - expect(extension).toMatchObject({ version: 'v1', namespace: 'test' }); + expect(extension).toMatchObject({ version: 'v2', namespace: 'test' }); expect(String(extension)).toBe( 'ExtensionDefinition{namespace=test,attachTo=root@default}', ); @@ -561,4 +558,179 @@ describe('createExtension', () => { }), ).toMatchObject({ version: 'v2' }); }); + + describe('overrides', () => { + it('should allow overriding of config and merging', () => { + const testExtension = createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'blob' }, + output: [stringDataRef], + config: { + schema: { + foo: z => z.string().optional(), + }, + }, + factory() { + return [stringDataRef('default')]; + }, + }); + + testExtension.override({ + config: { + schema: { + bar: z => z.string().optional(), + }, + }, + factory(_, { config }) { + return [stringDataRef(config.foo ?? config.bar ?? 'default')]; + }, + }); + + expect(true).toBe(true); + }); + + it('should allow overriding of outputs', () => { + const testExtension = createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'blob' }, + output: [stringDataRef], + inputs: { + test: createExtensionInput([stringDataRef], { + singleton: true, + }), + }, + config: { + schema: { + foo: z => z.string().optional(), + }, + }, + factory({ inputs }) { + return [stringDataRef(inputs.test.get(stringDataRef))]; + }, + }); + + const override1 = testExtension.override({ + output: [numberDataRef], + factory(_, { inputs }) { + return [numberDataRef(inputs.test.get(stringDataRef).length)]; + }, + }); + + // @ts-expect-error - this should fail because string output should be merged? + const override2 = testExtension.override({ + output: [numberDataRef], + factory(_, { inputs }) { + return [stringDataRef(inputs.test.get(stringDataRef))]; + }, + }); + + unused(override1, override2); + + expect(true).toBe(true); + }); + + it('should allow overriding the factory function and calling the original factory', () => { + const testExtension = createExtension({ + namespace: 'test', + attachTo: { id: 'root', input: 'blob' }, + output: [stringDataRef], + config: { + schema: { + foo: z => z.string().optional(), + }, + }, + factory() { + return [stringDataRef('default')]; + }, + }); + + testExtension.override({ + factory(originalFactory) { + const response = originalFactory(); + + const foo: string = response.get(stringDataRef); + + // @ts-expect-error - fails because original factory does not return number + const number: boolean = response.get(numberDataRef); + + return [stringDataRef(`foo-${foo}-override`)]; + }, + }); + + expect(true).toBe(true); + }); + + it('should allow overriding the returned values from the parent factory', () => { + const testExtension = createExtension({ + kind: 'thing', + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef, numberDataRef], + config: { + schema: { + foo: z => z.string().default('boom'), + }, + }, + factory({ config }) { + return [stringDataRef(config.foo), numberDataRef(42)]; + }, + }); + + const overridden = testExtension.override({ + output: [numberDataRef, stringDataRef], + *factory(originalFactory) { + const output = originalFactory(); + yield* output; + + yield numberDataRef(output.get(numberDataRef) + 1); + }, + }); + + const tester = createExtensionTester(overridden); + + expect(tester.data(numberDataRef)).toBe(43); + }); + + it('should work functionally with overrides', () => { + const testExtension = createExtension({ + kind: 'thing', + namespace: 'test', + attachTo: { id: 'root', input: 'default' }, + output: [stringDataRef], + config: { + schema: { + foo: z => z.string().default('boom'), + }, + }, + factory({ config }) { + return [stringDataRef(config.foo)]; + }, + }); + + const overriden = testExtension.override({ + config: { + schema: { + bar: z => z.string().default('hello'), + }, + }, + factory(originalFactory, { config }) { + const response = originalFactory(); + + const foo: string = response.get(stringDataRef); + + return [stringDataRef(`foo-${foo}-override-${config.bar}`)]; + }, + }); + + expect(createExtensionTester(overriden).data(stringDataRef)).toBe( + 'foo-boom-override-hello', + ); + + expect( + createExtensionTester(overriden, { + config: { foo: 'hello', bar: 'world' }, + }).data(stringDataRef), + ).toBe('foo-hello-override-world'); + }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 2a29fe1ab4..600b96ebe0 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -17,6 +17,7 @@ import { AppNode } from '../apis'; import { PortableSchema, createSchemaFromZod } from '../schema'; import { Expand } from '../types'; +import { createDataContainer } from './createExtensionBlueprint'; import { AnyExtensionDataRef, ExtensionDataRef, @@ -127,7 +128,6 @@ export interface LegacyCreateExtensionOptions< TInputs extends AnyExtensionInputMap, TConfig, TConfigInput, - TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, > { kind?: string; namespace?: string; @@ -136,21 +136,10 @@ export interface LegacyCreateExtensionOptions< disabled?: boolean; inputs?: TInputs; output: TOutput; - /** @deprecated - use `config.schema` instead */ configSchema?: PortableSchema; - config?: { - schema: TConfigSchema; - }; factory(context: { node: AppNode; - config: TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer< - ReturnType - >; - }); + config: TConfig; inputs: Expand>; }): Expand>; } @@ -192,8 +181,6 @@ export type CreateExtensionOptions< { optional: boolean; singleton: boolean } >; }, - TConfig, - TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, > = { @@ -204,27 +191,30 @@ export type CreateExtensionOptions< disabled?: boolean; inputs?: TInputs; output: Array; - /** @deprecated - use `config.schema` instead */ - configSchema?: PortableSchema; config?: { schema: TConfigSchema; }; factory(context: { node: AppNode; - config: TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer< - ReturnType - >; - }); + config: { + [key in keyof TConfigSchema]: z.infer>; + }; inputs: Expand>; }): Iterable; } & VerifyExtensionFactoryOutput; /** @public */ -export interface ExtensionDefinition { +export interface ExtensionDefinition< + TConfig, + TConfigInput = TConfig, + UOutput extends AnyExtensionDataRef = AnyExtensionDataRef, + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + } = {}, +> { $$type: '@backstage/ExtensionDefinition'; readonly kind?: string; readonly namespace?: string; @@ -232,6 +222,70 @@ export interface ExtensionDefinition { readonly attachTo: { id: string; input: string }; readonly disabled: boolean; readonly configSchema?: PortableSchema; + + override< + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UFactoryOutput extends ExtensionDataValue, + UNewOutput extends AnyExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + >( + args: { + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TExtraInputs & { + [KName in keyof TInputs]?: `Error: Input '${KName & + string}' is already defined in parent definition`; + }; + output?: Array; + config?: { + schema: TExtensionConfigSchema & { + [KName in keyof TConfig]?: `Error: Config key '${KName & + string}' is already defined in parent schema`; + }; + }; + factory( + originalFactory: (context?: { + config?: TConfig; + inputs?: Expand>; + }) => ExtensionDataContainer, + context: { + node: AppNode; + config: TConfig & { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable; + } & VerifyExtensionFactoryOutput< + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, + UFactoryOutput + >, + ): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & TConfig, + z.input< + z.ZodObject<{ + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + }> + > & + TConfigInput, + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, + TInputs & TExtraInputs + >; } /** @internal */ @@ -301,34 +355,26 @@ export function createExtension< { optional: boolean; singleton: boolean } >; }, - TConfig, - TConfigInput, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, >( options: CreateExtensionOptions< UOutput, TInputs, - TConfig, - TConfigInput, TConfigSchema, UFactoryOutput >, ): ExtensionDefinition< - TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer>; - }), - TConfigInput & - (string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >) + { + [key in keyof TConfigSchema]: z.infer>; + }, + z.input< + z.ZodObject<{ + [key in keyof TConfigSchema]: ReturnType; + }> + >, + UOutput, + TInputs >; /** * @public @@ -339,31 +385,14 @@ export function createExtension< TInputs extends AnyExtensionInputMap, TConfig, TConfigInput, - TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, >( options: LegacyCreateExtensionOptions< TOutput, TInputs, TConfig, - TConfigInput, - TConfigSchema + TConfigInput >, -): ExtensionDefinition< - TConfig & - (string extends keyof TConfigSchema - ? {} - : { - [key in keyof TConfigSchema]: z.infer>; - }), - TConfigInput & - (string extends keyof TConfigSchema - ? {} - : z.input< - z.ZodObject<{ - [key in keyof TConfigSchema]: ReturnType; - }> - >) ->; +): ExtensionDefinition; export function createExtension< UOutput extends AnyExtensionDataRef, TInputs extends { @@ -379,20 +408,12 @@ export function createExtension< UFactoryOutput extends ExtensionDataValue, >( options: - | CreateExtensionOptions< - UOutput, - TInputs, - TConfig, - TConfigInput, - TConfigSchema, - UFactoryOutput - > + | CreateExtensionOptions | LegacyCreateExtensionOptions< AnyExtensionDataMap, TLegacyInputs, TConfig, - TConfigInput, - TConfigSchema + TConfigInput >, ): ExtensionDefinition< TConfig & @@ -408,21 +429,29 @@ export function createExtension< z.ZodObject<{ [key in keyof TConfigSchema]: ReturnType; }> - >) + >), + UOutput, + TInputs > { - const newConfigSchema = options.config?.schema; - if (newConfigSchema && options.configSchema) { + if ('configSchema' in options && 'config' in options) { throw new Error(`Cannot provide both configSchema and config.schema`); } - const configSchema = newConfigSchema - ? createSchemaFromZod(innerZ => + let configSchema: PortableSchema | undefined; + if ('configSchema' in options) { + configSchema = options.configSchema; + } + if ('config' in options) { + const newConfigSchema = options.config?.schema; + configSchema = + newConfigSchema && + createSchemaFromZod(innerZ => innerZ.object( Object.fromEntries( Object.entries(newConfigSchema).map(([k, v]) => [k, v(innerZ)]), ), ), - ) - : options.configSchema; + ); + } return { $$type: '@backstage/ExtensionDefinition', @@ -450,6 +479,141 @@ export function createExtension< parts.push(`attachTo=${options.attachTo.id}@${options.attachTo.input}`); return `ExtensionDefinition{${parts.join(',')}}`; }, + override: < + TExtensionConfigSchema extends { + [key in string]: (zImpl: typeof z) => z.ZodType; + }, + UOverrideFactoryOutput extends ExtensionDataValue, + UNewOutput extends AnyExtensionDataRef, + TExtraInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, + >(overrideOptions: { + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TExtraInputs; + output?: Array; + config?: { + schema: TExtensionConfigSchema; + }; + factory( + originalFactory: (context?: { + config?: { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; + inputs?: Expand>; + }) => ExtensionDataContainer, + context: { + node: AppNode; + config: { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; + inputs: Expand>; + }, + ): Iterable; + }): ExtensionDefinition< + { + [key in keyof TExtensionConfigSchema]: z.infer< + ReturnType + >; + } & { + [key in keyof TConfigSchema]: z.infer>; + }, + z.input< + z.ZodObject< + { + [key in keyof TExtensionConfigSchema]: ReturnType< + TExtensionConfigSchema[key] + >; + } & { + [key in keyof TConfigSchema]: ReturnType; + } + > + >, + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, + TInputs & TExtraInputs + > => { + if (!Array.isArray(options.output)) { + throw new Error( + 'Cannot override an extension that is not declared using the new format with outputs as an array', + ); + } + const newOptions = options as CreateExtensionOptions< + UOutput, + TInputs, + TConfigSchema, + UFactoryOutput + >; + const overrideNewConfigSchema = overrideOptions.config?.schema; + + const schema = { + ...newOptions.config?.schema, + ...overrideNewConfigSchema, + } as TConfigSchema & TExtensionConfigSchema; + + return createExtension({ + kind: newOptions.kind, + namespace: newOptions.namespace, + name: newOptions.name, + attachTo: overrideOptions.attachTo ?? newOptions.attachTo, + disabled: overrideOptions.disabled ?? newOptions.disabled, + inputs: { ...overrideOptions.inputs, ...newOptions.inputs }, + output: overrideOptions.output ?? newOptions.output, + config: Object.keys(schema).length === 0 ? undefined : { schema }, + factory: ({ node, config, inputs }) => { + if (!overrideOptions.factory) { + return newOptions.factory({ + node, + config, + inputs: inputs as unknown as Expand< + ResolvedExtensionInputs + >, + }); + } + const parentResult = overrideOptions.factory( + (innerContext?: { + config?: { + [key in keyof TConfigSchema]: z.infer< + ReturnType + >; + }; + inputs?: Expand>; + }): ExtensionDataContainer => { + return createDataContainer( + newOptions.factory({ + node, + config: innerContext?.config ?? config, + inputs: (innerContext?.inputs ?? inputs) as any, // TODO: Fix the way input values are overridden + }) as Iterable, + ); + }, + { + node, + config, + inputs, + }, + ); + + const deduplicatedResult = new Map(); + for (const item of parentResult) { + deduplicatedResult.set(item.id, item); + } + + return deduplicatedResult.values() as Iterable; + }, + } as CreateExtensionOptions); + }, } as InternalExtensionDefinition< TConfig & (string extends keyof TConfigSchema diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index 66c3939a96..924eba2cba 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -69,6 +69,7 @@ describe('createExtensionBlueprint', () => { output: [coreExtensionData.reactElement], factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), version: 'v2', }); @@ -108,6 +109,7 @@ describe('createExtensionBlueprint', () => { output: [coreExtensionData.reactElement], factory: expect.any(Function), toString: expect.any(Function), + override: expect.any(Function), version: 'v2', }); @@ -389,6 +391,7 @@ describe('createExtensionBlueprint', () => { "output": [ [Function], ], + "override": [Function], "toString": [Function], "version": "v2", } diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 88b2523111..a8bf42c7f8 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -171,7 +171,7 @@ export interface ExtensionBlueprint< } /** @internal */ -function createDataContainer( +export function createDataContainer( values: Iterable< UData extends ExtensionDataRef ? ExtensionDataValue @@ -241,10 +241,7 @@ class ExtensionBlueprintImpl< name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; - inputs?: TExtraInputs & { - [KName in keyof TInputs]?: `Error: Input '${KName & - string}' is already defined in parent definition`; - }; + inputs?: TExtraInputs; output?: Array; params?: TParams; config?: { @@ -363,9 +360,7 @@ class ExtensionBlueprintImpl< } throw new Error('Either params or factory must be provided'); }, - } as CreateExtensionOptions< - UOutput, - TInputs & TExtraInputs, + } as CreateExtensionOptions) as ExtensionDefinition< { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType @@ -383,10 +378,8 @@ class ExtensionBlueprintImpl< [key in keyof TConfigSchema]: ReturnType; } > - >, - TConfigSchema, - UFactoryOutput - >); + > + >; } } diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index e1bfcbfc11..74d081e95d 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -23,6 +23,7 @@ describe('resolveExtensionDefinition', () => { version: 'v2', attachTo: { id: '', input: '' }, disabled: false, + override: () => ({} as ExtensionDefinition), }; it.each([ @@ -63,6 +64,7 @@ describe('old resolveExtensionDefinition', () => { version: 'v1', attachTo: { id: '', input: '' }, disabled: false, + override: () => ({} as ExtensionDefinition), }; it.each([ diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 18b6f0a653..0c8a7959f7 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -100,7 +100,13 @@ export function resolveExtensionDefinition( context?: { namespace?: string }, ): Extension { const internalDefinition = toInternalExtensionDefinition(definition); - const { name, kind, namespace: _, ...rest } = internalDefinition; + const { + name, + kind, + namespace: _skip1, + override: _skip2, + ...rest + } = internalDefinition; const namespace = internalDefinition.namespace ?? context?.namespace; const namePart = diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index bbe52c3fbc..ce7da3701d 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -169,7 +169,7 @@ export class ExtensionTester { : [...internal.output, coreExtensionData.routePath], factory: params => { const parentOutput = Array.from( - internal.factory(params) as Iterable< + internal.factory(params as any) as Iterable< ExtensionDataValue >, ).filter(val => val.id !== coreExtensionData.routePath.id); diff --git a/plugins/catalog-react/api-report-alpha.md b/plugins/catalog-react/api-report-alpha.md index 206c0160e2..3ff0788b04 100644 --- a/plugins/catalog-react/api-report-alpha.md +++ b/plugins/catalog-react/api-report-alpha.md @@ -115,7 +115,7 @@ export function createEntityCardExtension< config: TConfig; inputs: Expand>; }) => Promise; -}): ExtensionDefinition; +}): ExtensionDefinition; // @alpha (undocumented) export function createEntityContentExtension< @@ -148,7 +148,9 @@ export function createEntityContentExtension< filter?: string | undefined; title?: string | undefined; path?: string | undefined; - } + }, + never, + never >; // @alpha diff --git a/plugins/catalog/api-report-alpha.md b/plugins/catalog/api-report-alpha.md index 336bf4bc2b..726ee80573 100644 --- a/plugins/catalog/api-report-alpha.md +++ b/plugins/catalog/api-report-alpha.md @@ -108,7 +108,7 @@ export function createCatalogFilterExtension< inputs?: TInputs; configSchema?: PortableSchema; loader: (options: { config: TConfig }) => Promise; -}): ExtensionDefinition; +}): ExtensionDefinition; // @alpha (undocumented) const _default: BackstagePlugin< diff --git a/plugins/search-react/api-report-alpha.md b/plugins/search-react/api-report-alpha.md index c6e011fe41..56c0b6eab9 100644 --- a/plugins/search-react/api-report-alpha.md +++ b/plugins/search-react/api-report-alpha.md @@ -25,7 +25,7 @@ export function createSearchResultListItemExtension< }, >( options: SearchResultItemExtensionOptions, -): ExtensionDefinition; +): ExtensionDefinition; // @alpha (undocumented) export namespace createSearchResultListItemExtension { diff --git a/plugins/search/api-report-alpha.md b/plugins/search/api-report-alpha.md index e9b86f1f87..bb670522e4 100644 --- a/plugins/search/api-report-alpha.md +++ b/plugins/search/api-report-alpha.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { BackstagePlugin } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -17,7 +18,7 @@ const _default: BackstagePlugin< export default _default; // @alpha (undocumented) -export const searchApi: ExtensionDefinition<{}, {}>; +export const searchApi: ExtensionDefinition<{}, {}, never, never>; // @alpha (undocumented) export const searchNavItem: ExtensionDefinition< @@ -26,7 +27,9 @@ export const searchNavItem: ExtensionDefinition< }, { title?: string | undefined; - } + }, + never, + never >; // @alpha (undocumented) @@ -38,7 +41,9 @@ export const searchPage: ExtensionDefinition< { path: string; noTrack: boolean; - } + }, + AnyExtensionDataRef, + {} >; // (No @packageDocumentation comment for this package) diff --git a/plugins/techdocs/api-report-alpha.md b/plugins/techdocs/api-report-alpha.md index 04a0c5c512..e3c48ba214 100644 --- a/plugins/techdocs/api-report-alpha.md +++ b/plugins/techdocs/api-report-alpha.md @@ -37,7 +37,9 @@ export const techDocsSearchResultListItemExtension: ExtensionDefinition< asListItem: boolean; asLink: boolean; title?: string | undefined; - } + }, + never, + never >; // (No @packageDocumentation comment for this package) diff --git a/plugins/user-settings/api-report-alpha.md b/plugins/user-settings/api-report-alpha.md index fdb2c5f6bf..d0fad90e08 100644 --- a/plugins/user-settings/api-report-alpha.md +++ b/plugins/user-settings/api-report-alpha.md @@ -24,7 +24,9 @@ export const settingsNavItem: ExtensionDefinition< }, { title?: string | undefined; - } + }, + never, + never >; // @alpha (undocumented)