From c33f8df6ebc6516706cb6c7a04b2b33c67836118 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Aug 2024 16:34:52 +0200 Subject: [PATCH] frontend-plugin-api: initial support for blueprint input overrides Signed-off-by: Patrik Oldsberg --- .../wiring/createExtensionBlueprint.test.tsx | 246 +++++++++++++++++- .../src/wiring/createExtensionBlueprint.ts | 134 +++++++++- 2 files changed, 372 insertions(+), 8 deletions(-) diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index b9d1475074..003090668b 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -16,9 +16,15 @@ import React from 'react'; import { coreExtensionData } from './coreExtensionData'; -import { createExtensionBlueprint } from './createExtensionBlueprint'; +import { + createDataContainer, + createExtensionBlueprint, +} from './createExtensionBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; -import { createExtensionDataRef } from './createExtensionDataRef'; +import { + ExtensionDataValue, + createExtensionDataRef, +} from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; import { @@ -28,12 +34,15 @@ import { function unused(..._any: any[]) {} -function factoryOutput(ext: ExtensionDefinition) { +function factoryOutput( + ext: ExtensionDefinition, + inputs: unknown = undefined, +) { const int = toInternalExtensionDefinition(ext); if (int.version !== 'v2') { throw new Error('Expected v2 extension'); } - return Array.from(int.factory({} as any)); + return Array.from(int.factory({ inputs } as any)); } describe('createExtensionBlueprint', () => { @@ -335,6 +344,235 @@ describe('createExtensionBlueprint', () => { expect(true).toBe(true); }); + it('should be able to override inputs when calling original factory', () => { + const outputRef = createExtensionDataRef().with({ id: 'output' }); + const testDataRef1 = createExtensionDataRef().with({ id: 'test1' }); + const testDataRef2 = createExtensionDataRef().with({ id: 'test2' }); + + const Blueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + inputs: { + opt: createExtensionInput([testDataRef1.optional()], { + singleton: true, + optional: true, + }), + single: createExtensionInput([testDataRef1, testDataRef2.optional()], { + singleton: true, + }), + multi: createExtensionInput([testDataRef1]), + }, + output: [outputRef], + factory(_, { inputs }) { + return [ + outputRef({ + opt: inputs.opt?.get(testDataRef1) ?? 'none', + single: inputs.single.get(testDataRef1), + singleOpt: inputs.single.get(testDataRef2) ?? 'none', + multi: inputs.multi.map(i => i.get(testDataRef1)).join(','), + }), + ]; + }, + }); + + const mockInput = (node: string, ...data: ExtensionDataValue[]) => + Object.assign(createDataContainer(data), { + node, + }); + const mockParentInputs = { + opt: mockInput('node-opt', testDataRef1('orig-opt')), + single: mockInput('node-single', testDataRef1('orig-single')), + multi: [ + mockInput('node-multi1', testDataRef1('orig-multi1')), + mockInput('node-multi2', testDataRef1('orig-multi2')), + ], + }; + + // All values provided + expect( + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory) { + return origFactory( + {}, + { + inputs: { + opt: [testDataRef1('opt')], + single: [testDataRef1('single'), testDataRef2('singleOpt')], + multi: [[testDataRef1('multi1')], [testDataRef1('multi2')]], + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toEqual([ + outputRef({ + opt: 'opt', + single: 'single', + singleOpt: 'singleOpt', + multi: 'multi1,multi2', + }), + ]); + + // Minimal values provided + expect( + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory) { + return origFactory( + {}, + { + inputs: { + single: [testDataRef1('single')], + multi: [[testDataRef1('multi1')], [testDataRef1('multi2')]], + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toEqual([ + outputRef({ + opt: 'none', + single: 'single', + singleOpt: 'none', + multi: 'multi1,multi2', + }), + ]); + + // Not enough values provided, checked at runtime + expect(() => + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory) { + return origFactory( + {}, + { + inputs: { + single: [], + multi: [], + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid override provided for input 'multi', when overriding the input data the length must match the original input data"`, + ); + + // Wrong value provided + expect(() => + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory) { + return origFactory( + {}, + { + inputs: { + // @ts-expect-error + opt: [testDataRef2('opt')], + // @ts-expect-error + single: [testDataRef1('single'), outputRef({})], + multi: [ + // @ts-expect-error + [testDataRef2('multi1')], + ], + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid override provided for input 'multi', when overriding the input data the length must match the original input data"`, + ); + + // Forwarding entire inputs object + expect( + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory, { inputs }) { + return origFactory({}, { inputs }); + }, + }), + mockParentInputs, + ), + ).toEqual([ + outputRef({ + opt: 'orig-opt', + single: 'orig-single', + singleOpt: 'none', + multi: 'orig-multi1,orig-multi2', + }), + ]); + + // Forwarding individual outputs + expect( + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory, { inputs }) { + return origFactory( + {}, + { + inputs: { + opt: inputs.opt, + single: inputs.single, + multi: inputs.multi, + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toEqual([ + outputRef({ + opt: 'orig-opt', + single: 'orig-single', + singleOpt: 'none', + multi: 'orig-multi1,orig-multi2', + }), + ]); + + // Overriding based on original input + expect( + factoryOutput( + Blueprint.makeWithOverrides({ + factory(origFactory, { inputs }) { + return origFactory( + {}, + { + inputs: { + single: [ + testDataRef1(`override-${inputs.single.get(testDataRef1)}`), + testDataRef2('new-singleOpt'), + ], + multi: inputs.multi.map(i => [ + testDataRef1(`override-${i.get(testDataRef1)}`), + ]), + }, + }, + ); + }, + }), + mockParentInputs, + ), + ).toEqual([ + outputRef({ + opt: 'none', + single: 'override-orig-single', + singleOpt: 'new-singleOpt', + multi: 'override-orig-multi1,override-orig-multi2', + }), + ]); + }); + it('should allow merging of inputs', () => { const blueprint = createExtensionBlueprint({ kind: 'test-extension', diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 656e681a2b..2bc5fda43d 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -20,6 +20,7 @@ import { CreateExtensionOptions, ExtensionDataContainer, ExtensionDefinition, + ResolvedExtensionInput, ResolvedExtensionInputs, VerifyExtensionFactoryOutput, createExtension, @@ -29,6 +30,7 @@ import { ExtensionInput } from './createExtensionInput'; import { AnyExtensionDataRef, ExtensionDataRef, + ExtensionDataRefToValue, ExtensionDataValue, } from './createExtensionDataRef'; @@ -75,6 +77,61 @@ export type CreateExtensionBlueprintOptions< dataRefs?: TDataRefs; } & VerifyExtensionFactoryOutput; +/** @ignore */ +type ResolveInputValueOverrides< + TInputs extends { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + } = { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }, +> = Expand< + { + [KName in keyof TInputs as TInputs[KName] extends ExtensionInput< + any, + { + optional: infer IOptional extends boolean; + singleton: boolean; + } + > + ? IOptional extends true + ? never + : KName + : never]: TInputs[KName] extends ExtensionInput< + infer IDataRefs, + { optional: boolean; singleton: infer ISingleton extends boolean } + > + ? ISingleton extends true + ? Iterable> + : Array>> + : never; + } & { + [KName in keyof TInputs as TInputs[KName] extends ExtensionInput< + any, + { + optional: infer IOptional extends boolean; + singleton: boolean; + } + > + ? IOptional extends true + ? KName + : never + : never]?: TInputs[KName] extends ExtensionInput< + infer IDataRefs, + { optional: boolean; singleton: infer ISingleton extends boolean } + > + ? ISingleton extends true + ? Iterable> + : Array>> + : never; + } +>; + /** * @public */ @@ -156,7 +213,7 @@ export interface ExtensionBlueprint< params: TParams, context?: { config?: TConfig; - inputs?: Expand>; + inputs?: ResolveInputValueOverrides; }, ) => ExtensionDataContainer, context: { @@ -288,7 +345,7 @@ class ExtensionBlueprintImpl< ReturnType >; }; - inputs?: Expand>; + inputs?: ResolveInputValueOverrides; }, ) => ExtensionDataContainer, context: { @@ -347,14 +404,83 @@ class ExtensionBlueprintImpl< ReturnType >; }; - inputs?: Expand>; + inputs?: ResolveInputValueOverrides; }, ): ExtensionDataContainer => { + const overrideInputs = innerContext?.inputs; + const declaredInputs = this.options.inputs; + + let forwardedInputs = inputs; + if (overrideInputs && declaredInputs) { + const newInputs = {} as { + [KName in keyof TInputs]?: + | ({ node: AppNode } & ExtensionDataContainer) + | Array<{ node: AppNode } & ExtensionDataContainer>; + }; + forwardedInputs = newInputs as typeof inputs; + + for (const name in declaredInputs) { + if (!Object.hasOwn(declaredInputs, name)) { + continue; + } + const declaredInput = declaredInputs[name]; + const providedData = overrideInputs[name]; + if (declaredInput.config.singleton) { + // We know the passed in input will match the declared inputs, so this case is safe + const originalInput = ( + inputs as Record< + string, + { node: AppNode } & ExtensionDataContainer + > + )[name]; + if (providedData) { + const providedContainer = createDataContainer( + providedData as Iterable>, + ); + if (!originalInput) { + throw new Error( + `A data override was provided for input '${name}', but no original input was present.`, + ); + } + newInputs[name] = Object.assign(providedContainer, { + name: (originalInput as ResolvedExtensionInput).node, + }) as any; + } + } else { + // We know the passed in input will match the declared inputs, so this case is safe + const originalInput = ( + inputs as Record< + string, + Array<{ node: AppNode } & ExtensionDataContainer> + > + )[name]; + if (!Array.isArray(providedData)) { + throw new Error( + `Invalid override provided for input '${name}', expected an array`, + ); + } + if (originalInput.length !== providedData.length) { + throw new Error( + `Invalid override provided for input '${name}', when overriding the input data the length must match the original input data`, + ); + } + newInputs[name] = providedData.map((data, i) => { + const providedContainer = createDataContainer( + data as Iterable>, + ); + return Object.assign(providedContainer, { + name: (originalInput[i] as ResolvedExtensionInput) + .node, + }) as any; + }); + } + } + } 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 + inputs: forwardedInputs as any, // TODO: Might be able to improve this once legacy inputs are gone }), ); },