Merge pull request #25908 from backstage/mob/nfs/finish-bleuprints

NFS: Support merging of `inputs` in `Blueprints`
This commit is contained in:
Ben Lambert
2024-08-07 08:39:17 +02:00
committed by GitHub
5 changed files with 326 additions and 36 deletions
+5
View File
@@ -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.
+29 -10
View File
@@ -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<any, any>,
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<UExtraOutput>;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
@@ -1134,7 +1144,7 @@ export interface ExtensionBlueprint<
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Iterable<ExtensionDataRefToValue<UOutput>>,
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
@@ -1142,11 +1152,11 @@ export interface ExtensionBlueprint<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
UOutput & UExtraOutput,
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>)
| {
@@ -1186,7 +1196,17 @@ export interface ExtensionBoundaryProps {
// @public (undocumented)
export type ExtensionDataContainer<UExtensionData extends AnyExtensionDataRef> =
{
Iterable<
UExtensionData extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? IConfig['optional'] extends true
? never
: ExtensionDataValue<IData, IId>
: never
> & {
get<TId extends UExtensionData['id']>(
ref: ExtensionDataRef<any, TId, any>,
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
@@ -1356,7 +1376,6 @@ export const IconBundleBlueprint: ExtensionBlueprint<
}
>;
},
AnyExtensionDataRef,
{
icons: string;
test: string;
@@ -65,7 +65,17 @@ export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
/** @public */
export type ExtensionDataContainer<UExtensionData extends AnyExtensionDataRef> =
{
Iterable<
UExtensionData extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? IConfig['optional'] extends true
? never
: ExtensionDataValue<IData, IId>
: never
> & {
get<TId extends UExtensionData['id']>(
ref: ExtensionDataRef<any, TId, any>,
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
@@ -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<any, any>) {
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<string>().with({ id: 'test1' });
const testDataRef2 = createExtensionDataRef<string>().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<string>().with({ id: 'test1' });
const testDataRef2 = createExtensionDataRef<string>().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<string>().with({ id: 'test1' });
const testDataRef2 = createExtensionDataRef<string>().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<string>().with({ id: 'test1' });
const testDataRef2 = createExtensionDataRef<string>().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')]);
});
});
@@ -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<any, any>,
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<UExtraOutput>;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
@@ -124,7 +134,7 @@ export interface ExtensionBlueprint<
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Iterable<ExtensionDataRefToValue<UOutput>>,
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: TConfig & {
@@ -132,11 +142,11 @@ export interface ExtensionBlueprint<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
UOutput & UExtraOutput,
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
UFactoryOutput
>)
| {
@@ -160,6 +170,30 @@ export interface ExtensionBlueprint<
>;
}
/** @internal */
function createDataContainer<UData extends AnyExtensionDataRef>(
values: Iterable<
UData extends ExtensionDataRef<infer IData, infer IId>
? ExtensionDataValue<IData, IId>
: never
>,
): ExtensionDataContainer<UData> {
const container = new Map<string, ExtensionDataValue<any, any>>();
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<UData>;
}
/**
* @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<any, any>,
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<UExtraOutput>;
inputs?: TExtraInputs & {
[KName in keyof TInputs]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
params?: TParams;
config?: {
schema: TExtensionConfigSchema;
@@ -218,7 +261,7 @@ class ExtensionBlueprintImpl<
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Iterable<ExtensionDataRefToValue<UOutput>>,
) => ExtensionDataContainer<UOutput>,
context: {
node: AppNode;
config: {
@@ -228,7 +271,7 @@ class ExtensionBlueprintImpl<
} & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
}): 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<ResolvedExtensionInputs<TInputs>>;
},
): Iterable<ExtensionDataRefToValue<UOutput>> => {
return this.options.factory(innerParams, {
node,
config: innerContext?.config ?? config,
inputs: innerContext?.inputs ?? inputs,
});
): ExtensionDataContainer<UOutput> => {
return createDataContainer<UOutput>(
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<TInputs>
>,
});
}
throw new Error('Either params or factory must be provided');
},
} as CreateExtensionOptions<
UOutput,
TInputs,
TInputs & TExtraInputs,
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
@@ -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<any, any>,
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<ReturnType<TConfigSchema[key]>> },
@@ -391,7 +437,6 @@ export function createExtensionBlueprint<
TParams,
UOutput,
TInputs,
UExtraOutput,
string extends keyof TConfigSchema
? {}
: {