Merge pull request #25298 from backstage/blam/new-extension-types

NFE: Initial pass at another helper to create extension kinds
This commit is contained in:
Ben Lambert
2024-07-18 15:15:43 +02:00
committed by GitHub
7 changed files with 428 additions and 14 deletions
+31
View File
@@ -0,0 +1,31 @@
---
'@backstage/frontend-plugin-api': patch
---
Introduce a new way to encapsulate extension kinds that replaces the extension creator pattern with `createExtensionBlueprint`
This allows the creation of extension instances with the following pattern:
```tsx
// create the extension blueprint which is used to create instances
const EntityCardBlueprint = createExtensionBlueprint({
kind: 'entity-card',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
// create an instance of the extension blueprint with params
const testExtension = EntityCardBlueprint.make({
name: 'foo',
params: {
text: 'Hello World',
},
});
```
@@ -38,33 +38,31 @@ Note that while we use this naming pattern for the plugin instance this is only
| Description | Pattern | Examples |
| ----------- | ------------------------------- | ------------------------------------------------------------------- |
| Creator | `create<Kind>Extension` | `createPageExtension`, `createEntityCardExtension` |
| Blueprint | `<Kind>Blueprint` | `PageBlueprint`, `EntityCardBlueprint` |
| ID | `[<kind>:]<namespace>[/<name>]` | `'core.nav'`, `'page:user-settings'`, `'entity-card:catalog/about'` |
| Symbol | `<namespace>[<Name>][<Kind>]` | `coreNav`, `userSettingsPage`, `catalogAboutEntityCard` |
When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the extension creator function used to create the extension, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted.
When you create a new extension you never provide the ID directly. Instead, you indirectly or directly provide the kind, namespace, and name parts that make up the ID. The kind is always provided by the blueprint creator, the only exception is if you use `createExtension` directly. Any extension that is provided by a plugin will by default have its namespace set to the plugin ID, so you generally only need to provide an explicit namespace if you want to override an existing extension. The name is also optional, and primarily used to distinguish between multiple extensions of the same kind and namespace. If a plugin doesn't need to distinguish between different extensions of the same kind, the name can be omitted.
Example:
```ts
// This is an extension creator that is used to create an extension of the 'page' kind.
export function createPageExtension(options) {
return createExtension({
kind: 'page', // Kinds are kebab-case
// ...options
});
}
// This is an extension blueprint that is used to create an extension of the 'page' kind.
export const PageBlueprint = createExtensionBlueprint({
kind: 'page',
// ...
});
// The namespace is inferred from the plugin ID, in this case 'catalog'
// The final ID for this extension will be 'page:catalog/entity'
const catalogEntityPage = createPageExtension({
const catalogEntityPage = PageBlueprint.make({
name: 'entity',
// ...
});
// The name is omitted, because the catalog plugin only provides a single extension of this kind
// The final ID for this extension will be 'search-result-list-item:catalog'
const catalogSearchResultListItem = createSearchResultListItemExtension({
const catalogSearchResultListItem = SearchResultListItemBlueprint.make({
// ...
});
+85 -1
View File
@@ -499,6 +499,51 @@ export function createExtension<
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
): ExtensionDefinition<TConfig>;
// @public
export function createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
>(
options: CreateExtensionBlueprintOptions<TParams, TInputs, TOutput, TConfig>,
): ExtensionBlueprint<TParams, TInputs, TOutput, TConfig>;
// @public (undocumented)
export interface CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
// (undocumented)
attachTo: {
id: string;
input: string;
};
// (undocumented)
configSchema?: PortableSchema<TConfig>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
// @public (undocumented)
export function createExtensionDataRef<TData>(
id: string,
@@ -538,7 +583,7 @@ export interface CreateExtensionOptions<
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
@@ -833,6 +878,45 @@ export interface Extension<TConfig> {
readonly id: string;
}
// @public (undocumented)
export interface ExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
// (undocumented)
make(args: {
namespace?: string;
name?: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
configSchema?: PortableSchema<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig>;
}
// @public (undocumented)
export function ExtensionBoundary(
props: ExtensionBoundaryProps,
@@ -91,7 +91,7 @@ export interface CreateExtensionOptions<
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema<TConfig>;
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
@@ -115,7 +115,7 @@ export interface InternalExtensionDefinition<TConfig>
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(options: {
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<any>;
@@ -0,0 +1,105 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { coreExtensionData } from './coreExtensionData';
import { createExtensionBlueprint } from './createExtensionBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('createExtensionBlueprint', () => {
it('should allow creation of extension blueprints', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
const extension = TestExtensionBlueprint.make({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
});
expect(extension).toEqual({
$$type: '@backstage/ExtensionDefinition',
attachTo: {
id: 'test',
input: 'default',
},
configSchema: undefined,
disabled: false,
inputs: {},
kind: 'test-extension',
name: 'my-extension',
namespace: undefined,
output: {
element: {
$$type: '@backstage/ExtensionDataRef',
config: {},
id: 'core.reactElement',
optional: expect.any(Function),
toString: expect.any(Function),
},
},
factory: expect.any(Function),
toString: expect.any(Function),
version: 'v1',
});
const { container } = createExtensionTester(extension).render();
expect(container.querySelector('h1')).toHaveTextContent('Hello, world!');
});
it('should allow overriding of the default factory', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
},
});
const extension = TestExtensionBlueprint.make({
name: 'my-extension',
params: {
text: 'Hello, world!',
},
factory(params: { text: string }) {
return {
element: <h2>{params.text}</h2>,
};
},
});
expect(extension).toBeDefined();
const { container } = createExtensionTester(extension).render();
expect(container.querySelector('h2')).toHaveTextContent('Hello, world!');
});
});
@@ -0,0 +1,191 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppNode } from '../apis';
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataValues,
ExtensionDefinition,
ResolvedExtensionInputs,
createExtension,
} from './createExtension';
/**
* @public
*/
export interface CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
kind: string;
namespace?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: TOutput;
configSchema?: PortableSchema<TConfig>;
factory(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}
/**
* @public
*/
export interface ExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
make(args: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
configSchema?: PortableSchema<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig>;
}
/**
* @internal
*/
class ExtensionBlueprintImpl<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
> {
constructor(
private readonly options: CreateExtensionBlueprintOptions<
TParams,
TInputs,
TOutput,
TConfig
>,
) {}
public make(args: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
configSchema?: PortableSchema<TConfig>;
params: TParams;
factory?(
params: TParams,
context: {
node: AppNode;
config: TConfig;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
orignalFactory(
params?: TParams,
context?: {
node?: AppNode;
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}): ExtensionDefinition<TConfig> {
return createExtension({
kind: this.options.kind,
namespace: args.namespace ?? this.options.namespace,
name: args.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,
configSchema: args.configSchema ?? this.options.configSchema, // TODO: some config merging or smth
factory: ({ node, config, inputs }) => {
if (args.factory) {
return args.factory(args.params, {
node,
config,
inputs,
orignalFactory: (
innerParams?: TParams,
innerContext?: {
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) =>
this.options.factory(innerParams ?? args.params, {
node,
config: innerContext?.config ?? config,
inputs: innerContext?.inputs ?? inputs,
}),
});
}
return this.options.factory(args.params, {
node,
config,
inputs,
});
},
});
}
}
/**
* A simpler replacement for wrapping up `createExtension` inside a kind or type. This allows for a cleaner API for creating
* types and instances of those types.
*
* @public
*/
export function createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
TConfig,
>(
options: CreateExtensionBlueprintOptions<TParams, TInputs, TOutput, TConfig>,
): ExtensionBlueprint<TParams, TInputs, TOutput, TConfig> {
return new ExtensionBlueprintImpl(options);
}
@@ -48,3 +48,8 @@ export {
type FeatureFlagConfig,
type FrontendFeature,
} from './types';
export {
type CreateExtensionBlueprintOptions,
type ExtensionBlueprint,
createExtensionBlueprint,
} from './createExtensionBlueprint';