diff --git a/.changeset/six-mails-smell.md b/.changeset/six-mails-smell.md new file mode 100644 index 0000000000..3845c72626 --- /dev/null +++ b/.changeset/six-mails-smell.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Support merging of `inputs` in extension blueprints, but stop merging `output`. In addition, the original factory in extension blueprints now returns a data container that both provides access to the returned data, but can also be forwarded as output. diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index cbe92463ba..38b980fcbf 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -603,7 +603,6 @@ export function createExtensionBlueprint< } >; }, - UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; }, @@ -624,7 +623,6 @@ export function createExtensionBlueprint< TParams, UOutput, TInputs, - UExtraOutput, string extends keyof TConfigSchema ? {} : { @@ -1090,7 +1088,6 @@ export interface ExtensionBlueprint< } >; }, - UExtraOutput extends AnyExtensionDataRef, TConfig extends { [key in string]: unknown; }, @@ -1108,6 +1105,16 @@ export interface ExtensionBlueprint< [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: { namespace?: string; @@ -1117,8 +1124,11 @@ export interface ExtensionBlueprint< input: string; }; disabled?: boolean; - inputs?: TInputs; - output?: Array; + 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 & @@ -1134,7 +1144,7 @@ export interface ExtensionBlueprint< config?: TConfig; inputs?: Expand>; }, - ) => Iterable>, + ) => ExtensionDataContainer, context: { node: AppNode; config: TConfig & { @@ -1142,11 +1152,11 @@ export interface ExtensionBlueprint< ReturnType >; }; - inputs: Expand>; + inputs: Expand>; }, ): Iterable; } & VerifyExtensionFactoryOutput< - UOutput & UExtraOutput, + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, UFactoryOutput >) | { @@ -1186,7 +1196,17 @@ export interface ExtensionBoundaryProps { // @public (undocumented) export type ExtensionDataContainer = - { + Iterable< + UExtensionData extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? IConfig['optional'] extends true + ? never + : ExtensionDataValue + : never + > & { get( ref: ExtensionDataRef, ): UExtensionData extends ExtensionDataRef @@ -1356,7 +1376,6 @@ export const IconBundleBlueprint: ExtensionBlueprint< } >; }, - AnyExtensionDataRef, { icons: string; test: string; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index a8bf4ef17c..2a29fe1ab4 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -65,7 +65,17 @@ export type ExtensionDataValues = { /** @public */ export type ExtensionDataContainer = - { + Iterable< + UExtensionData extends ExtensionDataRef< + infer IData, + infer IId, + infer IConfig + > + ? IConfig['optional'] extends true + ? never + : ExtensionDataValue + : never + > & { get( ref: ExtensionDataRef, ): UExtensionData extends ExtensionDataRef diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index dfdbfface6..66c3939a96 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -21,9 +21,21 @@ import { createExtensionTester } from '@backstage/frontend-test-utils'; import { createExtensionDataRef } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; +import { + ExtensionDefinition, + toInternalExtensionDefinition, +} from './createExtension'; function unused(..._any: any[]) {} +function factoryOutput(ext: ExtensionDefinition) { + const int = toInternalExtensionDefinition(ext); + if (int.version !== 'v2') { + throw new Error('Expected v2 extension'); + } + return Array.from(int.factory({} as any)); +} + describe('createExtensionBlueprint', () => { it('should allow creation of extension blueprints', () => { const TestExtensionBlueprint = createExtensionBlueprint({ @@ -384,4 +396,203 @@ describe('createExtensionBlueprint', () => { expect(true).toBe(true); }); + + it('should allow merging of inputs', () => { + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + inputs: { + test: createExtensionInput([coreExtensionData.routeRef], { + singleton: true, + }), + }, + output: [coreExtensionData.reactElement.optional()], + factory(_params: { x?: string }, { inputs }) { + const ref: RouteRef = inputs.test.get(coreExtensionData.routeRef); + + unused(ref); + return []; + }, + }); + + blueprint.make({ + inputs: { + test2: createExtensionInput([coreExtensionData.reactElement], { + singleton: true, + }), + }, + factory(origFactory, { inputs }) { + const ref: RouteRef = inputs.test.get(coreExtensionData.routeRef); + + const el: JSX.Element = inputs.test2.get( + coreExtensionData.reactElement, + ); + + unused(ref, el); + + return origFactory({}); + }, + }); + + expect(true).toBe(true); + }); + + it('should not allow overriding inputs', () => { + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + inputs: { + test: createExtensionInput([coreExtensionData.routeRef]), + }, + output: [coreExtensionData.reactElement.optional()], + factory() { + return []; + }, + }); + + blueprint.make({ + inputs: { + // @ts-expect-error + test: createExtensionInput([]), // Overrides are not allowed + }, + factory(origFactory) { + return origFactory({}); + }, + }); + + expect(true).toBe(true); + }); + + it('should replace the outputs when provided through make', () => { + const testDataRef1 = createExtensionDataRef().with({ id: 'test1' }); + const testDataRef2 = createExtensionDataRef().with({ id: 'test2' }); + + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: [testDataRef1], + factory() { + return [testDataRef1('foo')]; + }, + }); + + const ext = toInternalExtensionDefinition( + blueprint.make({ + output: [testDataRef2], + factory(origFactory) { + const parent = origFactory({}); + return [testDataRef2(`${parent.get(testDataRef1)}bar`)]; + }, + }), + ); + + expect(ext.output).toEqual([testDataRef2]); + + expect(factoryOutput(ext)).toEqual([testDataRef2('foobar')]); + }); + + it('should allow returning of the parent data container', () => { + const testDataRef1 = createExtensionDataRef().with({ id: 'test1' }); + const testDataRef2 = createExtensionDataRef().with({ id: 'test2' }); + + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: [testDataRef1], + factory() { + return [testDataRef1('foo')]; + }, + }); + + expect( + factoryOutput( + blueprint.make({ + output: [testDataRef1, testDataRef2], + *factory(origFactory) { + yield* origFactory({}); + yield testDataRef2('bar'); + }, + }), + ), + ).toEqual([testDataRef1('foo'), testDataRef2('bar')]); + + expect( + factoryOutput( + blueprint.make({ + output: [testDataRef1, testDataRef2], + factory(origFactory) { + return [...origFactory({}), testDataRef2('bar')]; + }, + }), + ), + ).toEqual([testDataRef1('foo'), testDataRef2('bar')]); + }); + + it('should not allow returning parent output if outputs are overridden', () => { + const testDataRef1 = createExtensionDataRef().with({ id: 'test1' }); + const testDataRef2 = createExtensionDataRef().with({ id: 'test2' }); + + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: [testDataRef1.optional()], + factory() { + return [testDataRef1('foo')]; + }, + }); + + // @ts-expect-error + blueprint.make({ + output: [testDataRef2.optional()], + *factory() { + yield testDataRef1('foo'); + yield testDataRef2('bar'); + }, + }); + + expect( + factoryOutput( + blueprint.make({ + output: [testDataRef2.optional()], + *factory() { + yield testDataRef2('bar'); + }, + }), + ), + ).toEqual([testDataRef2('bar')]); + }); + + it('should not rely on optional outputs when forwarding from parent', () => { + const testDataRef1 = createExtensionDataRef().with({ id: 'test1' }); + const testDataRef2 = createExtensionDataRef().with({ id: 'test2' }); + + const blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: [testDataRef1, testDataRef2.optional()], + factory() { + return [testDataRef1('foo')]; + }, + }); + + // @ts-expect-error + blueprint.make({ + output: [testDataRef1, testDataRef2], + *factory(origFactory) { + yield* origFactory({}); + }, + }); + + expect( + factoryOutput( + blueprint.make({ + output: [testDataRef1, testDataRef2], + *factory(origFactory) { + yield* origFactory({}); + yield testDataRef2('bar'); + }, + }), + ), + ).toEqual([testDataRef1('foo'), testDataRef2('bar')]); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 46db278bf8..2b80dac81e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -18,6 +18,7 @@ import { AppNode } from '../apis'; import { Expand } from '../types'; import { CreateExtensionOptions, + ExtensionDataContainer, ExtensionDefinition, ResolvedExtensionInputs, VerifyExtensionFactoryOutput, @@ -27,7 +28,7 @@ import { z } from 'zod'; import { ExtensionInput } from './createExtensionInput'; import { AnyExtensionDataRef, - ExtensionDataRefToValue, + ExtensionDataRef, ExtensionDataValue, } from './createExtensionDataRef'; @@ -83,7 +84,6 @@ export interface ExtensionBlueprint< { optional: boolean; singleton: boolean } >; }, - UExtraOutput extends AnyExtensionDataRef, TConfig extends { [key in string]: unknown }, TConfigInput extends { [key in string]: unknown }, TDataRefs extends { [name in string]: AnyExtensionDataRef }, @@ -101,14 +101,24 @@ export interface ExtensionBlueprint< [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: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; - inputs?: TInputs; - output?: Array; + 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 & @@ -124,7 +134,7 @@ export interface ExtensionBlueprint< config?: TConfig; inputs?: Expand>; }, - ) => Iterable>, + ) => ExtensionDataContainer, context: { node: AppNode; config: TConfig & { @@ -132,11 +142,11 @@ export interface ExtensionBlueprint< ReturnType >; }; - inputs: Expand>; + inputs: Expand>; }, ): Iterable; } & VerifyExtensionFactoryOutput< - UOutput & UExtraOutput, + AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, UFactoryOutput >) | { @@ -160,6 +170,30 @@ export interface ExtensionBlueprint< >; } +/** @internal */ +function createDataContainer( + values: Iterable< + UData extends ExtensionDataRef + ? ExtensionDataValue + : never + >, +): ExtensionDataContainer { + const container = new Map>(); + + for (const output of values) { + container.set(output.id, output); + } + + return { + get(ref) { + return container.get(ref.id)?.value; + }, + [Symbol.iterator]() { + return container.values(); + }, + } as ExtensionDataContainer; +} + /** * @internal */ @@ -172,7 +206,6 @@ class ExtensionBlueprintImpl< { optional: boolean; singleton: boolean } >; }, - UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, TDataRefs extends { [name in string]: AnyExtensionDataRef }, > { @@ -196,13 +229,23 @@ class ExtensionBlueprintImpl< [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: { namespace?: string; name?: string; attachTo?: { id: string; input: string }; disabled?: boolean; - inputs?: TInputs; - output?: Array; + inputs?: TExtraInputs & { + [KName in keyof TInputs]?: `Error: Input '${KName & + string}' is already defined in parent definition`; + }; + output?: Array; params?: TParams; config?: { schema: TExtensionConfigSchema; @@ -218,7 +261,7 @@ class ExtensionBlueprintImpl< }; inputs?: Expand>; }, - ) => Iterable>, + ) => ExtensionDataContainer, context: { node: AppNode; config: { @@ -228,7 +271,7 @@ class ExtensionBlueprintImpl< } & { [key in keyof TConfigSchema]: z.infer>; }; - inputs: Expand>; + inputs: Expand>; }, ): Iterable; }): ExtensionDefinition< @@ -277,8 +320,8 @@ class ExtensionBlueprintImpl< name: args.name ?? name, attachTo: args.attachTo ?? this.options.attachTo, disabled: args.disabled ?? this.options.disabled, - inputs: args.inputs ?? this.options.inputs, - output: [...(args.output ?? []), ...this.options.output], + inputs: { ...args.inputs, ...this.options.inputs }, + output: args.output ?? this.options.output, config: Object.keys(schema).length === 0 ? undefined : { schema }, factory: ({ node, config, inputs }) => { if (args.factory) { @@ -293,12 +336,14 @@ class ExtensionBlueprintImpl< }; inputs?: Expand>; }, - ): Iterable> => { - return this.options.factory(innerParams, { - node, - config: innerContext?.config ?? config, - inputs: innerContext?.inputs ?? inputs, - }); + ): ExtensionDataContainer => { + return createDataContainer( + this.options.factory(innerParams, { + node, + config: innerContext?.config ?? config, + inputs: (innerContext?.inputs ?? inputs) as any, // TODO: Fix the way input values are overridden + }), + ); }, { node, @@ -310,14 +355,17 @@ class ExtensionBlueprintImpl< return this.options.factory(args.params, { node, config, - inputs, + // TODO: Figure out types once legacy data map input type is gone + inputs: inputs as unknown as Expand< + ResolvedExtensionInputs + >, }); } throw new Error('Either params or factory must be provided'); }, } as CreateExtensionOptions< UOutput, - TInputs, + TInputs & TExtraInputs, { [key in keyof TExtensionConfigSchema]: z.infer< ReturnType @@ -357,7 +405,6 @@ export function createExtensionBlueprint< { optional: boolean; singleton: boolean } >; }, - UExtraOutput extends AnyExtensionDataRef, TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { [name in string]: AnyExtensionDataRef } = never, @@ -374,7 +421,6 @@ export function createExtensionBlueprint< TParams, UOutput, TInputs, - UExtraOutput, string extends keyof TConfigSchema ? {} : { [key in keyof TConfigSchema]: z.infer> }, @@ -391,7 +437,6 @@ export function createExtensionBlueprint< TParams, UOutput, TInputs, - UExtraOutput, string extends keyof TConfigSchema ? {} : {