Merge pull request #25806 from backstage/mob/ext-data-refactor

frontend-plugin-api: move away from data maps when declaring extension inputs and outputs
This commit is contained in:
Ben Lambert
2024-07-29 15:13:28 +02:00
committed by GitHub
22 changed files with 2592 additions and 861 deletions
+2 -6
View File
@@ -11,13 +11,9 @@ This allows the creation of extension instances with the following pattern:
const EntityCardBlueprint = createExtensionBlueprint({
kind: 'entity-card',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
return [coreExtensionData.reactElement(<h1>{params.text}</h1>)];
},
});
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-test-utils': patch
'@backstage/frontend-app-api': patch
---
Added support for v2 extensions, which declare their inputs and outputs without using a data map.
+57
View File
@@ -0,0 +1,57 @@
---
'@backstage/frontend-plugin-api': patch
---
Extensions have been changed to be declared with an array of inputs and outputs, rather than a map of named data refs. This change was made to reduce confusion around the role of the input and output names, as well as enable more powerful APIs for overriding extensions.
An extension that was previously declared like this:
```tsx
const exampleExtension = createExtension({
name: 'example',
inputs: {
items: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
return {
element: (
<div>
Example
{inputs.items.map(item => {
return <div>{item.output.element}</div>;
})}
</div>
),
};
},
});
```
Should be migrated to the following:
```tsx
const exampleExtension = createExtension({
name: 'example',
inputs: {
items: createExtensionInput([coreExtensionData.reactElement]),
},
output: [coreExtensionData.reactElement],
factory({ inputs }) {
return [
coreExtensionData.reactElement(
<div>
Example
{inputs.items.map(item => {
return <div>{item.get(coreExtensionData.reactElement)}</div>;
})}
</div>,
),
];
},
});
```
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,11 @@
import {
AnyExtensionDataMap,
AnyExtensionDataRef,
AnyExtensionInputMap,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
@@ -29,7 +32,7 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
function resolveInputData(
function resolveInputDataMap(
dataMap: AnyExtensionDataMap,
attachment: AppNode,
inputName: string,
@@ -54,35 +57,76 @@ function resolveInputData(
});
}
function resolveInputs(
function resolveInputDataContainer(
extensionData: Array<AnyExtensionDataRef>,
attachment: AppNode,
inputName: string,
): { node: AppNode } & ExtensionDataContainer<AnyExtensionDataRef> {
const dataMap = new Map<string, unknown>();
for (const ref of extensionData) {
if (dataMap.has(ref.id)) {
throw new Error(`Unexpected duplicate input data '${ref.id}'`);
}
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = extensionData
.filter(r => !r.config.optional)
.map(r => `'${r.id}'`)
.join(', ');
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
.map(r => `'${r.id}'`)
.join(', ');
throw new Error(
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
);
}
dataMap.set(ref.id, value);
}
return {
node: attachment,
get(ref) {
return dataMap.get(ref.id);
},
} as { node: AppNode } & ExtensionDataContainer<AnyExtensionDataRef>;
}
function reportUndeclaredAttachments(
id: string,
inputMap: AnyExtensionInputMap,
inputMap: { [name in string]: unknown },
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
if (process.env.NODE_ENV !== 'production') {
const inputNames = Object.keys(inputMap);
const inputNames = Object.keys(inputMap);
for (const [name, nodes] of undeclaredAttachments) {
const pl = nodes.length > 1;
// eslint-disable-next-line no-console
console.warn(
[
`The extension${pl ? 's' : ''} '${nodes
.map(n => n.spec.id)
.join("', '")}' ${pl ? 'are' : 'is'}`,
`attached to the input '${name}' of the extension '${id}', but it`,
inputNames.length === 0
? 'has no inputs'
: `has no such input (candidates are '${inputNames.join("', '")}')`,
].join(' '),
);
}
for (const [name, nodes] of undeclaredAttachments) {
const pl = nodes.length > 1;
// eslint-disable-next-line no-console
console.warn(
[
`The extension${pl ? 's' : ''} '${nodes
.map(n => n.spec.id)
.join("', '")}' ${pl ? 'are' : 'is'}`,
`attached to the input '${name}' of the extension '${id}', but it`,
inputNames.length === 0
? 'has no inputs'
: `has no such input (candidates are '${inputNames.join("', '")}')`,
].join(' '),
);
}
}
function resolveV1Inputs(
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<AnyExtensionInputMap> {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
@@ -104,7 +148,7 @@ function resolveInputs(
}
return {
node: attachedNodes[0],
output: resolveInputData(
output: resolveInputDataMap(
input.extensionData,
attachedNodes[0],
inputName,
@@ -114,11 +158,62 @@ function resolveInputs(
return attachedNodes.map(attachment => ({
node: attachment,
output: resolveInputData(input.extensionData, attachment, inputName),
output: resolveInputDataMap(input.extensionData, attachment, inputName),
}));
}) as ResolvedExtensionInputs<AnyExtensionInputMap>;
}
function resolveV2Inputs(
inputMap: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
attachments: ReadonlyMap<string, AppNode[]>,
): ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}> {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
if (input.config.singleton) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
"', '",
)}'`,
);
} else if (attachedNodes.length === 0) {
if (input.config.optional) {
return undefined;
}
throw Error(`input '${inputName}' is required but was not received`);
}
return resolveInputDataContainer(
input.extensionData,
attachedNodes[0],
inputName,
);
}
return attachedNodes.map(attachment =>
resolveInputDataContainer(input.extensionData, attachment, inputName),
);
}) as ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}
/** @internal */
export function createAppNodeInstance(options: {
node: AppNode;
@@ -141,24 +236,71 @@ export function createAppNodeInstance(options: {
try {
const internalExtension = toInternalExtension(extension);
const namedOutputs = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveInputs(id, internalExtension.inputs, attachments),
});
if (process.env.NODE_ENV !== 'production') {
reportUndeclaredAttachments(id, internalExtension.inputs, attachments);
}
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = internalExtension.output[name];
if (!ref) {
throw new Error(`unknown output provided via '${name}'`);
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = internalExtension.output[name];
if (!ref) {
throw new Error(`unknown output provided via '${name}'`);
}
if (extensionData.has(ref.id)) {
throw new Error(
`duplicate extension data '${ref.id}' received via output '${name}'`,
);
}
extensionData.set(ref.id, output);
extensionDataRefs.add(ref);
}
if (extensionData.has(ref.id)) {
} else if (internalExtension.version === 'v2') {
const outputDataValues = internalExtension.factory({
node,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
});
const outputDataMap = new Map<string, unknown>();
for (const value of outputDataValues) {
if (outputDataMap.has(value.id)) {
throw new Error(`duplicate extension data output '${value.id}'`);
}
outputDataMap.set(value.id, value.value);
}
for (const ref of internalExtension.output) {
const value = outputDataMap.get(ref.id);
outputDataMap.delete(ref.id);
if (value === undefined) {
if (!ref.config.optional) {
throw new Error(
`missing required extension data output '${ref.id}'`,
);
}
} else {
extensionData.set(ref.id, value);
extensionDataRefs.add(ref);
}
}
if (outputDataMap.size > 0) {
throw new Error(
`duplicate extension data '${ref.id}' received via output '${name}'`,
`unexpected output '${Array.from(outputDataMap.keys()).join(
"', '",
)}'`,
);
}
extensionData.set(ref.id, output);
extensionDataRefs.add(ref);
} else {
throw new Error(
`unexpected extension version '${(internalExtension as any).version}'`,
);
}
} catch (e) {
throw new Error(
+324 -114
View File
@@ -147,20 +147,23 @@ export { AnyApiFactory };
export { AnyApiRef };
// @public (undocumented)
// @public @deprecated (undocumented)
export type AnyExtensionDataMap = {
[name in string]: ExtensionDataRef<
unknown,
string,
{
optional?: true;
}
>;
[name in string]: AnyExtensionDataRef;
};
// @public (undocumented)
export type AnyExtensionDataRef = ExtensionDataRef<
unknown,
string,
{
optional?: true;
}
>;
// @public @deprecated (undocumented)
export type AnyExtensionInputMap = {
[inputName in string]: ExtensionInput<
[inputName in string]: LegacyExtensionInput<
AnyExtensionDataMap,
{
optional: boolean;
@@ -318,16 +321,18 @@ export { configApiRef };
// @public (undocumented)
export interface ConfigurableExtensionDataRef<
TId extends string,
TData,
TId extends string,
TConfig extends {
optional?: true;
} = {},
> extends ExtensionDataRef<TData, TId, TConfig> {
// (undocumented)
(t: TData): ExtensionDataValue<TData, TId>;
// (undocumented)
optional(): ConfigurableExtensionDataRef<
TId,
TData,
TId,
TData & {
optional: true;
}
@@ -351,14 +356,14 @@ export type CoreErrorBoundaryFallbackProps = {
// @public (undocumented)
export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef<
'core.reactElement',
JSX_2.Element,
'core.reactElement',
{}
>;
routePath: ConfigurableExtensionDataRef<'core.routing.path', string, {}>;
routePath: ConfigurableExtensionDataRef<string, 'core.routing.path', {}>;
routeRef: ConfigurableExtensionDataRef<
'core.routing.ref',
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{}
>;
};
@@ -397,8 +402,8 @@ export function createApiExtension<
export namespace createApiExtension {
const // (undocumented)
factoryDataRef: ConfigurableExtensionDataRef<
'core.api.factory',
AnyApiFactory,
'core.api.factory',
{}
>;
}
@@ -455,10 +460,10 @@ export function createAppRootWrapperExtension<
export namespace createAppRootWrapperExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
'app.root.wrapper',
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.root.wrapper',
{}
>;
}
@@ -493,11 +498,11 @@ export function createComponentExtension<
export namespace createComponentExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
'core.component.component',
{
ref: ComponentRef;
impl: ComponentType;
},
'core.component.component',
{}
>;
}
@@ -508,6 +513,50 @@ export function createComponentRef<T extends {} = {}>(options: {
}): ComponentRef<T>;
// @public (undocumented)
export function createExtension<
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
TConfig,
TConfigInput,
TConfigSchema extends {
[key: string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
options: CreateExtensionOptions<
UOutput,
TInputs,
TConfig,
TConfigInput,
TConfigSchema,
UFactoryOutput
>,
): ExtensionDefinition<
TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
}),
TConfigInput &
(string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>)
>;
// @public @deprecated (undocumented)
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
@@ -517,7 +566,7 @@ export function createExtension<
[key: string]: (zImpl: typeof z) => z.ZodType;
},
>(
options: CreateExtensionOptions<
options: LegacyCreateExtensionOptions<
TOutput,
TInputs,
TConfig,
@@ -544,24 +593,38 @@ export function createExtension<
// @public
export function createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
UExtraOutput extends AnyExtensionDataRef,
TConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TDataRefs extends AnyExtensionDataMap = never,
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends {
[name in string]: AnyExtensionDataRef;
} = never,
>(
options: CreateExtensionBlueprintOptions<
TParams,
UOutput,
TInputs,
TOutput,
TConfigSchema,
UFactoryOutput,
TDataRefs
>,
): ExtensionBlueprint<
TParams,
UOutput,
TInputs,
TOutput,
UExtraOutput,
string extends keyof TConfigSchema
? {}
: {
@@ -578,29 +641,38 @@ export function createExtensionBlueprint<
>;
// @public (undocumented)
export interface CreateExtensionBlueprintOptions<
export type CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
TConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
TDataRefs extends AnyExtensionDataMap,
> {
// (undocumented)
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends {
[name in string]: AnyExtensionDataRef;
},
> = {
kind: string;
namespace?: string;
attachTo: {
id: string;
input: string;
};
// (undocumented)
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
schema: TConfigSchema;
};
// (undocumented)
dataRefs?: TDataRefs;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(
params: TParams,
context: {
@@ -610,41 +682,34 @@ export interface CreateExtensionBlueprintOptions<
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>;
// @public @deprecated (undocumented)
export function createExtensionDataRef<TData>(
id: string,
): ConfigurableExtensionDataRef<string, TData>;
): ConfigurableExtensionDataRef<TData, string>;
// @public (undocumented)
export function createExtensionDataRef<TData>(): {
with<TId extends string>(options: {
id: TId;
}): ConfigurableExtensionDataRef<TId, TData>;
}): ConfigurableExtensionDataRef<TData, TId>;
};
// @public (undocumented)
// @public @deprecated (undocumented)
export function createExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends {
singleton?: boolean;
optional?: boolean;
},
>(
extensionData: TExtensionData,
extensionData: TExtensionDataMap,
config?: TConfig,
): ExtensionInput<
TExtensionData,
): LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
@@ -652,29 +717,62 @@ export function createExtensionInput<
>;
// @public (undocumented)
export interface CreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
export function createExtensionInput<
UExtensionData extends ExtensionDataRef<
unknown,
string,
{
optional?: true;
}
>,
TConfig extends {
singleton?: boolean;
optional?: boolean;
},
>(
extensionData: Array<UExtensionData>,
config?: TConfig,
): ExtensionInput<
UExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
// @public (undocumented)
export type CreateExtensionOptions<
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
TConfig,
TConfigInput,
TConfigSchema extends {
[key: string]: (zImpl: typeof z) => z.ZodType;
},
> {
// (undocumented)
UFactoryOutput extends ExtensionDataValue<any, any>,
> = {
kind?: string;
namespace?: string;
name?: string;
attachTo: {
id: string;
input: string;
};
// (undocumented)
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
configSchema?: PortableSchema<TConfig, TConfigInput>;
config?: {
schema: TConfigSchema;
};
// @deprecated (undocumented)
configSchema?: PortableSchema<TConfig, TConfigInput>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(context: {
node: AppNode;
config: TConfig &
@@ -686,18 +784,8 @@ export interface CreateExtensionOptions<
>;
});
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
}): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>;
// @public (undocumented)
export function createExtensionOverrides(
@@ -750,12 +838,12 @@ export function createNavItemExtension(options: {
export namespace createNavItemExtension {
const // (undocumented)
targetDataRef: ConfigurableExtensionDataRef<
'core.nav-item.target',
{
title: string;
icon: IconComponent_2;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
}
@@ -772,11 +860,11 @@ export function createNavLogoExtension(options: {
export namespace createNavLogoExtension {
const // (undocumented)
logoElementsDataRef: ConfigurableExtensionDataRef<
'core.nav-logo.logo-elements',
{
logoIcon?: JSX.Element | undefined;
logoFull?: JSX.Element | undefined;
},
'core.nav-logo.logo-elements',
{}
>;
}
@@ -866,10 +954,10 @@ export function createRouterExtension<
export namespace createRouterExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
'app.router.wrapper',
React_2.ComponentType<{
children?: React_2.ReactNode;
}>,
'app.router.wrapper',
{}
>;
}
@@ -903,8 +991,8 @@ export function createSignInPageExtension<
export namespace createSignInPageExtension {
const // (undocumented)
componentDataRef: ConfigurableExtensionDataRef<
'core.sign-in-page.component',
React_2.ComponentType<SignInPageProps>,
'core.sign-in-page.component',
{}
>;
}
@@ -927,8 +1015,8 @@ export function createThemeExtension(
export namespace createThemeExtension {
const // (undocumented)
themeDataRef: ConfigurableExtensionDataRef<
'core.theme.theme',
AppTheme,
'core.theme.theme',
{}
>;
}
@@ -943,7 +1031,6 @@ export function createTranslationExtension(options: {
export namespace createTranslationExtension {
const // (undocumented)
translationDataRef: ConfigurableExtensionDataRef<
'core.translation.translation',
| TranslationResource<string>
| TranslationMessages<
string,
@@ -952,6 +1039,7 @@ export namespace createTranslationExtension {
},
boolean
>,
'core.translation.translation',
{}
>;
}
@@ -994,15 +1082,26 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
// @public (undocumented)
export interface ExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{
optional: boolean;
singleton: boolean;
}
>;
},
UExtraOutput extends AnyExtensionDataRef,
TConfig extends {
[key in string]: unknown;
},
TConfigInput extends {
[key in string]: unknown;
},
TDataRefs extends AnyExtensionDataMap,
TDataRefs extends {
[name in string]: AnyExtensionDataRef;
},
> {
// (undocumented)
dataRefs: TDataRefs;
@@ -1010,6 +1109,7 @@ export interface ExtensionBlueprint<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
args: {
namespace?: string;
@@ -1020,7 +1120,7 @@ export interface ExtensionBlueprint<
};
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
output?: Array<UExtraOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
@@ -1028,7 +1128,7 @@ export interface ExtensionBlueprint<
};
};
} & (
| {
| ({
factory(
originalFactory: (
params: TParams,
@@ -1036,7 +1136,7 @@ export interface ExtensionBlueprint<
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Expand<ExtensionDataValues<TOutput>>,
) => Iterable<ExtensionDataRefToValue<UOutput>>,
context: {
node: AppNode;
config: TConfig & {
@@ -1046,8 +1146,11 @@ export interface ExtensionBlueprint<
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
UOutput & UExtraOutput,
UFactoryOutput
>)
| {
params: TParams;
}
@@ -1083,6 +1186,18 @@ export interface ExtensionBoundaryProps {
routable?: boolean;
}
// @public (undocumented)
export type ExtensionDataContainer<UExtensionData extends AnyExtensionDataRef> =
{
get<TId extends UExtensionData['id']>(
ref: ExtensionDataRef<any, TId, any>,
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
? IConfig['optional'] extends true
? IData | undefined
: IData
: never;
};
// @public (undocumented)
export type ExtensionDataRef<
TData,
@@ -1091,13 +1206,26 @@ export type ExtensionDataRef<
optional?: true;
} = {},
> = {
id: TId;
T: TData;
config: TConfig;
$$type: '@backstage/ExtensionDataRef';
readonly $$type: '@backstage/ExtensionDataRef';
readonly id: TId;
readonly T: TData;
readonly config: TConfig;
};
// @public
// @public (undocumented)
export type ExtensionDataRefToValue<TDataRef extends AnyExtensionDataRef> =
TDataRef extends ExtensionDataRef<infer IData, infer IId, any>
? ExtensionDataValue<IData, IId>
: never;
// @public (undocumented)
export type ExtensionDataValue<TData, TId extends string> = {
readonly $$type: '@backstage/ExtensionDataValue';
readonly id: TId;
readonly value: TData;
};
// @public @deprecated
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
@@ -1135,7 +1263,13 @@ export interface ExtensionDefinition<TConfig, TConfigInput = TConfig> {
// @public (undocumented)
export interface ExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TExtensionData extends ExtensionDataRef<
unknown,
string,
{
optional?: true;
}
>,
TConfig extends {
singleton: boolean;
optional: boolean;
@@ -1146,7 +1280,7 @@ export interface ExtensionInput<
// (undocumented)
config: TConfig;
// (undocumented)
extensionData: TExtensionData;
extensionData: Array<TExtensionData>;
}
// @public (undocumented)
@@ -1211,16 +1345,23 @@ export const IconBundleBlueprint: ExtensionBlueprint<
[x: string]: IconComponent;
};
},
AnyExtensionInputMap,
ConfigurableExtensionDataRef<
{
[x: string]: IconComponent;
},
'core.icons',
{}
>,
{
icons: ConfigurableExtensionDataRef<
'core.icons',
[x: string]: ExtensionInput<
AnyExtensionDataRef,
{
[x: string]: IconComponent;
},
{}
optional: boolean;
singleton: boolean;
}
>;
},
AnyExtensionDataRef,
{
icons: string;
test: string;
@@ -1231,10 +1372,10 @@ export const IconBundleBlueprint: ExtensionBlueprint<
},
{
icons: ConfigurableExtensionDataRef<
'core.icons',
{
[x: string]: IconComponent;
},
'core.icons',
{}
>;
}
@@ -1265,6 +1406,70 @@ export { IdentityApi };
export { identityApiRef };
// @public @deprecated (undocumented)
export interface LegacyCreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
TConfigSchema extends {
[key: string]: (zImpl: typeof z) => z.ZodType;
},
> {
// (undocumented)
attachTo: {
id: string;
input: string;
};
// (undocumented)
config?: {
schema: TConfigSchema;
};
// @deprecated (undocumented)
configSchema?: PortableSchema<TConfig, TConfigInput>;
// (undocumented)
disabled?: boolean;
// (undocumented)
factory(context: {
node: AppNode;
config: TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
});
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
// (undocumented)
namespace?: string;
// (undocumented)
output: TOutput;
}
// @public @deprecated (undocumented)
export interface LegacyExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends {
singleton: boolean;
optional: boolean;
},
> {
// (undocumented)
$$type: '@backstage/ExtensionInput';
// (undocumented)
config: TConfig;
// (undocumented)
extensionData: TExtensionDataMap;
}
export { microsoftAuthApiRef };
export { OAuthApi };
@@ -1315,25 +1520,30 @@ export { ProfileInfo };
export { ProfileInfoApi };
// @public
export type ResolvedExtensionInput<TExtensionData extends AnyExtensionDataMap> =
{
node: AppNode;
output: ExtensionDataValues<TExtensionData>;
};
export type ResolvedExtensionInput<
TExtensionInput extends ExtensionInput<any, any>,
> = TExtensionInput['extensionData'] extends Array<AnyExtensionDataRef>
? {
node: AppNode;
} & ExtensionDataContainer<TExtensionInput['extensionData'][number]>
: TExtensionInput['extensionData'] extends AnyExtensionDataMap
? {
node: AppNode;
output: ExtensionDataValues<TExtensionInput['extensionData']>;
}
: never;
// @public
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput<any, any>;
[name in string]: ExtensionInput<any, any> | LegacyExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>>
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>
: Expand<
ResolvedExtensionInput<TInputs[InputName]['extensionData']> | undefined
>;
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
};
// @public
@@ -25,16 +25,16 @@ export const IconBundleBlueprint = createExtensionBlueprint({
kind: 'icon-bundle',
namespace: 'app',
attachTo: { id: 'app', input: 'icons' },
output: {
icons: iconsDataRef,
},
output: [iconsDataRef],
config: {
schema: {
icons: z => z.string().default('blob'),
test: z => z.string(),
},
},
factory: (params: { icons: { [key in string]: IconComponent } }) => params,
factory: (params: { icons: { [key in string]: IconComponent } }) => [
iconsDataRef(params.icons),
],
dataRefs: {
icons: iconsDataRef,
},
@@ -18,7 +18,8 @@ import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { createExtensionInput } from './createExtensionInput';
const stringData = createExtensionDataRef<string>().with({ id: 'string' });
const stringDataRef = createExtensionDataRef<string>().with({ id: 'string' });
const numberDataRef = createExtensionDataRef<number>().with({ id: 'number' });
function unused(..._any: any[]) {}
@@ -28,7 +29,7 @@ describe('createExtension', () => {
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
foo: stringDataRef,
},
};
const extension = createExtension({
@@ -39,14 +40,14 @@ describe('createExtension', () => {
};
},
});
expect(extension.namespace).toBe('test');
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
// When declared as an error function without a block the TypeScript errors
// are a more specific and will point at the property that is problematic.
// are a more specific and will often point at the property that is problematic.
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
// @ts-expect-error
foo: 3,
}),
});
@@ -166,8 +167,8 @@ describe('createExtension', () => {
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
bar: stringData.optional(),
foo: stringDataRef,
bar: stringDataRef.optional(),
},
};
const extension = createExtension({
@@ -176,7 +177,7 @@ describe('createExtension', () => {
foo: 'bar',
}),
});
expect(extension.namespace).toBe('test');
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
createExtension({
...baseConfig,
@@ -185,18 +186,18 @@ describe('createExtension', () => {
bar: 'baz',
}),
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
// @ts-expect-error
foo: 3,
}),
});
// @ts-expect-error
createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
// @ts-expect-error
bar: 3,
}),
});
@@ -237,18 +238,18 @@ describe('createExtension', () => {
attachTo: { id: 'root', input: 'default' },
inputs: {
mixed: createExtensionInput({
required: stringData,
optional: stringData.optional(),
required: stringDataRef,
optional: stringDataRef.optional(),
}),
onlyRequired: createExtensionInput({
required: stringData,
required: stringDataRef,
}),
onlyOptional: createExtensionInput({
optional: stringData.optional(),
optional: stringDataRef.optional(),
}),
},
output: {
foo: stringData,
foo: stringDataRef,
},
factory({ inputs }) {
const a1: string = inputs.mixed?.[0].output.required;
@@ -286,7 +287,7 @@ describe('createExtension', () => {
};
},
});
expect(extension.namespace).toBe('test');
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
expect(String(extension)).toBe(
'ExtensionDefinition{namespace=test,attachTo=root@default}',
);
@@ -304,7 +305,7 @@ describe('createExtension', () => {
},
},
output: {
foo: stringData,
foo: stringDataRef,
},
factory({ config }) {
const a1: string = config.foo;
@@ -324,7 +325,7 @@ describe('createExtension', () => {
};
},
});
expect(extension.namespace).toBe('test');
expect(extension).toMatchObject({ version: 'v1', namespace: 'test' });
expect(String(extension)).toBe(
'ExtensionDefinition{namespace=test,attachTo=root@default}',
);
@@ -355,4 +356,110 @@ describe('createExtension', () => {
return extension.configSchema?.parse({});
}).toThrow("Missing required value at 'foo'");
});
it('should support new form of outputs', () => {
expect(
// @ts-expect-error
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return []; // Missing all outputs
},
}),
).toMatchObject({ version: 'v2' });
expect(
// @ts-expect-error
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return [stringDataRef('hello')]; // Missing number output
},
}),
).toMatchObject({ version: 'v2' });
// Duplicate output, we won't attempt to handle this a compile time and instead error out at runtime
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef],
factory() {
return [stringDataRef('hello'), stringDataRef('hello')];
},
}),
).toMatchObject({ version: 'v2' });
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
}),
).toMatchObject({ version: 'v2' });
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef.optional()],
factory() {
return [stringDataRef('hello'), numberDataRef(4)];
},
}),
).toMatchObject({ version: 'v2' });
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
output: [stringDataRef, numberDataRef.optional()],
factory() {
return [stringDataRef('hello')]; // Missing number output, but it's optional so that's allowed
},
}),
).toMatchObject({ version: 'v2' });
});
it('should support new form of inputs', () => {
expect(
createExtension({
namespace: 'test',
attachTo: { id: 'root', input: 'default' },
inputs: {
header: createExtensionInput([stringDataRef.optional()], {
optional: true,
singleton: true,
}),
content: createExtensionInput([stringDataRef, numberDataRef], {
optional: false,
singleton: true,
}),
},
output: [stringDataRef],
factory({ inputs }) {
const headerStr = inputs.header?.get(stringDataRef);
const contentStr = inputs.content.get(stringDataRef);
const contentNum = inputs.content.get(numberDataRef);
// @ts-expect-error
inputs.header?.get(numberDataRef);
// @ts-expect-error
const x1: string = headerStr; // string | undefined
unused(x1);
return [stringDataRef(contentStr.repeat(contentNum))];
},
}),
).toMatchObject({ version: 'v2' });
});
});
@@ -17,17 +17,28 @@
import { AppNode } from '../apis';
import { PortableSchema, createSchemaFromZod } from '../schema';
import { Expand } from '../types';
import { ExtensionDataRef } from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import {
AnyExtensionDataRef,
ExtensionDataRef,
ExtensionDataValue,
} from './createExtensionDataRef';
import { ExtensionInput, LegacyExtensionInput } from './createExtensionInput';
import { z } from 'zod';
/** @public */
/**
* @public
* @deprecated Extension data maps will be removed.
*/
export type AnyExtensionDataMap = {
[name in string]: ExtensionDataRef<unknown, string, { optional?: true }>;
[name in string]: AnyExtensionDataRef;
};
/** @public */
/**
* @public
* @deprecated This type will be removed.
*/
export type AnyExtensionInputMap = {
[inputName in string]: ExtensionInput<
[inputName in string]: LegacyExtensionInput<
AnyExtensionDataMap,
{ optional: boolean; singleton: boolean }
>;
@@ -36,6 +47,7 @@ export type AnyExtensionInputMap = {
/**
* Converts an extension data map into the matching concrete data values type.
* @public
* @deprecated Extension data maps will be removed.
*/
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
@@ -51,34 +63,56 @@ export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
: never]?: TExtensionData[DataName]['T'];
};
/** @public */
export type ExtensionDataContainer<UExtensionData extends AnyExtensionDataRef> =
{
get<TId extends UExtensionData['id']>(
ref: ExtensionDataRef<any, TId, any>,
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
? IConfig['optional'] extends true
? IData | undefined
: IData
: never;
};
/**
* Convert a single extension input into a matching resolved input.
* @public
*/
export type ResolvedExtensionInput<TExtensionData extends AnyExtensionDataMap> =
{
node: AppNode;
output: ExtensionDataValues<TExtensionData>;
};
export type ResolvedExtensionInput<
TExtensionInput extends ExtensionInput<any, any>,
> = TExtensionInput['extensionData'] extends Array<AnyExtensionDataRef>
? {
node: AppNode;
} & ExtensionDataContainer<TExtensionInput['extensionData'][number]>
: TExtensionInput['extensionData'] extends AnyExtensionDataMap
? {
node: AppNode;
output: ExtensionDataValues<TExtensionInput['extensionData']>;
}
: never;
/**
* Converts an extension input map into a matching collection of resolved inputs.
* @public
*/
export type ResolvedExtensionInputs<
TInputs extends { [name in string]: ExtensionInput<any, any> },
TInputs extends {
[name in string]: ExtensionInput<any, any> | LegacyExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>>
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>
: Expand<
ResolvedExtensionInput<TInputs[InputName]['extensionData']> | undefined
>;
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
};
/** @public */
export interface CreateExtensionOptions<
/**
* @public
* @deprecated This way of structuring the options is deprecated, this type will be removed in the future
*/
export interface LegacyCreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
TConfig,
@@ -111,6 +145,67 @@ export interface CreateExtensionOptions<
}): Expand<ExtensionDataValues<TOutput>>;
}
/** @ignore */
export type VerifyExtensionFactoryOutput<
UDeclaredOutput extends AnyExtensionDataRef,
UFactoryOutput extends ExtensionDataValue<any, any>,
> = (
UDeclaredOutput extends any
? UDeclaredOutput['config']['optional'] extends true
? never
: UDeclaredOutput['id']
: never
) extends infer IRequiredOutputIds
? [IRequiredOutputIds] extends [UFactoryOutput['id']]
? {}
: {
'Error: The extension factory is missing the following outputs': Exclude<
IRequiredOutputIds,
UFactoryOutput['id']
>;
}
: never;
/** @public */
export type CreateExtensionOptions<
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
TConfig,
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
> = {
kind?: string;
namespace?: string;
name?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
/** @deprecated - use `config.schema` instead */
configSchema?: PortableSchema<TConfig, TConfigInput>;
config?: {
schema: TConfigSchema;
};
factory(context: {
node: AppNode;
config: TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<
ReturnType<TConfigSchema[key]>
>;
});
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>;
/** @public */
export interface ExtensionDefinition<TConfig, TConfigInput = TConfig> {
$$type: '@backstage/ExtensionDefinition';
@@ -123,17 +218,40 @@ export interface ExtensionDefinition<TConfig, TConfigInput = TConfig> {
}
/** @internal */
export interface InternalExtensionDefinition<TConfig, TConfigInput>
extends ExtensionDefinition<TConfig, TConfigInput> {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
}
export type InternalExtensionDefinition<TConfig, TConfigInput> =
ExtensionDefinition<TConfig, TConfigInput> &
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
}
| {
readonly version: 'v2';
readonly inputs: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtensionDefinition<TConfig, TConfigInput>(
@@ -148,15 +266,57 @@ export function toInternalExtensionDefinition<TConfig, TConfigInput>(
`Invalid extension definition instance, bad type '${internal.$$type}'`,
);
}
if (internal.version !== 'v1') {
const version = internal.version;
if (version !== 'v1' && version !== 'v2') {
throw new Error(
`Invalid extension definition instance, bad version '${internal.version}'`,
`Invalid extension definition instance, bad version '${version}'`,
);
}
return internal;
}
/** @public */
export function createExtension<
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
TConfig,
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
options: CreateExtensionOptions<
UOutput,
TInputs,
TConfig,
TConfigInput,
TConfigSchema,
UFactoryOutput
>,
): ExtensionDefinition<
TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
}),
TConfigInput &
(string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>)
>;
/**
* @public
* @deprecated - use the array format of `output` instead, see TODO-doc-link
*/
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends AnyExtensionInputMap,
@@ -164,7 +324,7 @@ export function createExtension<
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
>(
options: CreateExtensionOptions<
options: LegacyCreateExtensionOptions<
TOutput,
TInputs,
TConfig,
@@ -186,6 +346,52 @@ export function createExtension<
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>)
>;
export function createExtension<
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
TLegacyInputs extends AnyExtensionInputMap,
TConfig,
TConfigInput,
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
options:
| CreateExtensionOptions<
UOutput,
TInputs,
TConfig,
TConfigInput,
TConfigSchema,
UFactoryOutput
>
| LegacyCreateExtensionOptions<
AnyExtensionDataMap,
TLegacyInputs,
TConfig,
TConfigInput,
TConfigSchema
>,
): ExtensionDefinition<
TConfig &
(string extends keyof TConfigSchema
? {}
: {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
}),
TConfigInput &
(string extends keyof TConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>)
> {
const newConfigSchema = options.config?.schema;
if (newConfigSchema && options.configSchema) {
@@ -203,7 +409,7 @@ export function createExtension<
return {
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
version: Symbol.iterator in options.output ? 'v2' : 'v1',
kind: options.kind,
namespace: options.namespace,
name: options.name,
@@ -212,16 +418,7 @@ export function createExtension<
inputs: options.inputs ?? {},
output: options.output,
configSchema,
factory({ inputs, config, ...rest }) {
// TODO: Simplify this, but TS wouldn't infer the input type for some reason
return options.factory({
inputs: inputs as Expand<ResolvedExtensionInputs<TInputs>>,
config: config as TConfig & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
...rest,
});
},
factory: options.factory,
toString() {
const parts: string[] = [];
if (options.kind) {
@@ -27,13 +27,9 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
return [coreExtensionData.reactElement(<h1>{params.text}</h1>)];
},
});
@@ -56,18 +52,10 @@ describe('createExtensionBlueprint', () => {
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),
},
},
output: [coreExtensionData.reactElement],
factory: expect.any(Function),
toString: expect.any(Function),
version: 'v1',
version: 'v2',
});
const { container } = createExtensionTester(extension).render();
@@ -78,13 +66,9 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
return [coreExtensionData.reactElement(<h1>{params.text}</h1>)];
},
});
@@ -109,16 +93,12 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
dataRefs: {
data: dataRef,
},
factory(params: { text: string }) {
return {
element: <h1>{params.text}</h1>,
};
return [coreExtensionData.reactElement(<h1>{params.text}</h1>)];
},
});
@@ -131,9 +111,7 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
config: {
schema: {
text: z => z.string(),
@@ -148,9 +126,7 @@ describe('createExtensionBlueprint', () => {
expect(config.text).toBe('Hello, world!');
return {
element: <h1>{config.text}</h1>,
};
return [coreExtensionData.reactElement(<h1>{config.text}</h1>)];
},
});
@@ -194,18 +170,14 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
config: {
schema: {
text: z => z.string(),
},
},
factory(params: { text: string }) {
return {
element: <div>{params.text}</div>,
};
return [coreExtensionData.reactElement(<div>{params.text}</div>)];
},
});
@@ -230,16 +202,12 @@ describe('createExtensionBlueprint', () => {
const TestExtensionBlueprint = createExtensionBlueprint({
kind: 'test-extension',
attachTo: { id: 'test', input: 'default' },
output: {
element: coreExtensionData.reactElement,
},
output: [coreExtensionData.reactElement],
factory(_, { config }) {
// @ts-expect-error
const b = config.something;
return {
element: <div />,
};
return [coreExtensionData.reactElement(<div />)];
},
});
@@ -17,31 +17,42 @@
import { AppNode } from '../apis';
import { Expand } from '../types';
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataValues,
CreateExtensionOptions,
ExtensionDefinition,
ResolvedExtensionInputs,
VerifyExtensionFactoryOutput,
createExtension,
} from './createExtension';
import { z } from 'zod';
import { ExtensionInput } from './createExtensionInput';
import {
AnyExtensionDataRef,
ExtensionDataRefToValue,
ExtensionDataValue,
} from './createExtensionDataRef';
/**
* @public
*/
export interface CreateExtensionBlueprintOptions<
export type CreateExtensionBlueprintOptions<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
TDataRefs extends AnyExtensionDataMap,
> {
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> = {
kind: string;
namespace?: string;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: TOutput;
output: Array<UOutput>;
config?: {
schema: TConfigSchema;
};
@@ -54,21 +65,27 @@ export interface CreateExtensionBlueprintOptions<
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
): Iterable<UFactoryOutput>;
dataRefs?: TDataRefs;
}
} & VerifyExtensionFactoryOutput<UOutput, UFactoryOutput>;
/**
* @public
*/
export interface ExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
UExtraOutput extends AnyExtensionDataRef,
TConfig extends { [key in string]: unknown },
TConfigInput extends { [key in string]: unknown },
TDataRefs extends AnyExtensionDataMap,
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> {
dataRefs: TDataRefs;
@@ -82,6 +99,7 @@ export interface ExtensionBlueprint<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
args: {
namespace?: string;
@@ -89,7 +107,7 @@ export interface ExtensionBlueprint<
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
output?: Array<UExtraOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof TConfig]?: `Error: Config key '${KName &
@@ -97,7 +115,7 @@ export interface ExtensionBlueprint<
};
};
} & (
| {
| ({
factory(
originalFactory: (
params: TParams,
@@ -105,7 +123,7 @@ export interface ExtensionBlueprint<
config?: TConfig;
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Expand<ExtensionDataValues<TOutput>>,
) => Iterable<ExtensionDataRefToValue<UOutput>>,
context: {
node: AppNode;
config: TConfig & {
@@ -115,8 +133,11 @@ export interface ExtensionBlueprint<
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
}
): Iterable<UFactoryOutput>;
} & VerifyExtensionFactoryOutput<
UOutput & UExtraOutput,
UFactoryOutput
>)
| {
params: TParams;
}
@@ -143,17 +164,24 @@ export interface ExtensionBlueprint<
*/
class ExtensionBlueprintImpl<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
UExtraOutput extends AnyExtensionDataRef,
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
TDataRefs extends AnyExtensionDataMap,
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> {
constructor(
private readonly options: CreateExtensionBlueprintOptions<
TParams,
UOutput,
TInputs,
TOutput,
TConfigSchema,
any,
TDataRefs
>,
) {
@@ -166,13 +194,14 @@ class ExtensionBlueprintImpl<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
>(args: {
namespace?: string;
name?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output?: TOutput;
output?: Array<UExtraOutput>;
params?: TParams;
config?: {
schema: TExtensionConfigSchema;
@@ -188,7 +217,7 @@ class ExtensionBlueprintImpl<
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) => Expand<ExtensionDataValues<TOutput>>,
) => Iterable<ExtensionDataRefToValue<UOutput>>,
context: {
node: AppNode;
config: {
@@ -200,7 +229,7 @@ class ExtensionBlueprintImpl<
};
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
},
): Expand<ExtensionDataValues<TOutput>>;
): Iterable<UFactoryOutput>;
}): ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
@@ -232,7 +261,7 @@ class ExtensionBlueprintImpl<
attachTo: args.attachTo ?? this.options.attachTo,
disabled: args.disabled ?? this.options.disabled,
inputs: args.inputs ?? this.options.inputs,
output: args.output ?? this.options.output,
output: [...(args.output ?? []), ...this.options.output],
config: Object.keys(schema).length === 0 ? undefined : { schema },
factory: ({ node, config, inputs }) => {
if (args.factory) {
@@ -247,12 +276,13 @@ class ExtensionBlueprintImpl<
};
inputs?: Expand<ResolvedExtensionInputs<TInputs>>;
},
) =>
this.options.factory(innerParams, {
): Iterable<ExtensionDataRefToValue<UOutput>> => {
return this.options.factory(innerParams, {
node,
config: innerContext?.config ?? config,
inputs: innerContext?.inputs ?? inputs,
}),
});
},
{
node,
config,
@@ -268,7 +298,30 @@ class ExtensionBlueprintImpl<
}
throw new Error('Either params or factory must be provided');
},
});
} as CreateExtensionOptions<
UOutput,
TInputs,
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
z.input<
z.ZodObject<
{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}
>
>,
TConfigSchema,
UFactoryOutput
>);
}
}
@@ -280,22 +333,31 @@ class ExtensionBlueprintImpl<
*/
export function createExtensionBlueprint<
TParams,
TInputs extends AnyExtensionInputMap,
TOutput extends AnyExtensionDataMap,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
},
UExtraOutput extends AnyExtensionDataRef,
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
TDataRefs extends AnyExtensionDataMap = never,
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends { [name in string]: AnyExtensionDataRef } = never,
>(
options: CreateExtensionBlueprintOptions<
TParams,
UOutput,
TInputs,
TOutput,
TConfigSchema,
UFactoryOutput,
TDataRefs
>,
): ExtensionBlueprint<
TParams,
UOutput,
TInputs,
TOutput,
UExtraOutput,
string extends keyof TConfigSchema
? {}
: { [key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>> },
@@ -310,8 +372,9 @@ export function createExtensionBlueprint<
> {
return new ExtensionBlueprintImpl(options) as ExtensionBlueprint<
TParams,
UOutput,
TInputs,
TOutput,
UExtraOutput,
string extends keyof TConfigSchema
? {}
: {
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { createExtensionDataRef } from './createExtensionDataRef';
import {
ExtensionDataValue,
createExtensionDataRef,
} from './createExtensionDataRef';
describe('createExtensionDataRef', () => {
it('can be created and read', () => {
@@ -34,4 +37,18 @@ describe('createExtensionDataRef', () => {
expect(refOptional.id).toBe('foo');
expect(String(refOptional)).toBe('ExtensionDataRef{id=foo,optional=true}');
});
it('can be used to encapsulate a value', () => {
const ref = createExtensionDataRef<string>().with({ id: 'foo' });
const val: ExtensionDataValue<string, 'foo'> = ref('hello');
expect(val).toEqual({
$$type: '@backstage/ExtensionDataValue',
id: 'foo',
value: 'hello',
});
// @ts-expect-error
ref(3);
// @ts-expect-error
ref();
});
});
@@ -14,29 +14,50 @@
* limitations under the License.
*/
/** @public */
export type ExtensionDataValue<TData, TId extends string> = {
readonly $$type: '@backstage/ExtensionDataValue';
readonly id: TId;
readonly value: TData;
};
/** @public */
export type ExtensionDataRef<
TData,
TId extends string = string,
TConfig extends { optional?: true } = {},
> = {
id: TId;
T: TData;
config: TConfig;
$$type: '@backstage/ExtensionDataRef';
readonly $$type: '@backstage/ExtensionDataRef';
readonly id: TId;
readonly T: TData;
readonly config: TConfig;
};
/** @public */
export type ExtensionDataRefToValue<TDataRef extends AnyExtensionDataRef> =
TDataRef extends ExtensionDataRef<infer IData, infer IId, any>
? ExtensionDataValue<IData, IId>
: never;
/** @public */
export type AnyExtensionDataRef = ExtensionDataRef<
unknown,
string,
{ optional?: true }
>;
/** @public */
export interface ConfigurableExtensionDataRef<
TId extends string,
TData,
TId extends string,
TConfig extends { optional?: true } = {},
> extends ExtensionDataRef<TData, TId, TConfig> {
optional(): ConfigurableExtensionDataRef<
TId,
TData,
TId,
TData & { optional: true }
>;
(t: TData): ExtensionDataValue<TData, TId>;
}
/**
@@ -45,36 +66,43 @@ export interface ConfigurableExtensionDataRef<
*/
export function createExtensionDataRef<TData>(
id: string,
): ConfigurableExtensionDataRef<string, TData>;
): ConfigurableExtensionDataRef<TData, string>;
/** @public */
export function createExtensionDataRef<TData>(): {
with<TId extends string>(options: {
id: TId;
}): ConfigurableExtensionDataRef<TId, TData>;
}): ConfigurableExtensionDataRef<TData, TId>;
};
export function createExtensionDataRef<TData>(id?: string):
| ConfigurableExtensionDataRef<string, TData>
| ConfigurableExtensionDataRef<TData, string>
| {
with<TId extends string>(options: {
id: TId;
}): ConfigurableExtensionDataRef<TId, TData>;
}): ConfigurableExtensionDataRef<TData, TId>;
} {
const createRef = <TId extends string>(refId: TId) =>
({
id: refId,
$$type: '@backstage/ExtensionDataRef',
config: {},
optional() {
return {
...this,
config: { ...this.config, optional: true },
};
},
toString() {
const optional = Boolean(this.config.optional);
return `ExtensionDataRef{id=${refId},optional=${optional}}`;
},
} as ConfigurableExtensionDataRef<TId, TData, { optional?: true }>);
Object.assign(
(value: TData): ExtensionDataValue<TData, TId> => ({
$$type: '@backstage/ExtensionDataValue',
id: refId,
value,
}),
{
id: refId,
$$type: '@backstage/ExtensionDataRef',
config: {},
optional() {
return {
...this,
config: { ...this.config, optional: true },
};
},
toString() {
const optional = Boolean(this.config.optional);
return `ExtensionDataRef{id=${refId},optional=${optional}}`;
},
} as ConfigurableExtensionDataRef<TData, TId, { optional?: true }>,
);
if (id) {
return createRef(id);
}
@@ -0,0 +1,169 @@
/*
* Copyright 2023 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 { createExtensionDataRef } from './createExtensionDataRef';
import {
ExtensionInput,
LegacyExtensionInput,
createExtensionInput,
} from './createExtensionInput';
const stringDataRef = createExtensionDataRef<string>().with({ id: 'str' });
const numberDataRef = createExtensionDataRef<number>().with({ id: 'num' });
function unused(..._any: any[]) {}
describe('createExtensionInput', () => {
it('should create a regular input', () => {
const input = createExtensionInput([stringDataRef, numberDataRef]);
expect(input).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: false, optional: false },
});
const x1: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x2: ExtensionInput<
typeof stringDataRef,
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x3: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: true; optional: false }
> = input;
// @ts-expect-error
const x4: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: true }
> = input;
unused(x1, x2, x3, x4);
});
it('should create a singleton input', () => {
const input = createExtensionInput([stringDataRef, numberDataRef], {
singleton: true,
});
expect(input).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: true, optional: false },
});
const x1: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: true; optional: false }
> = input;
// @ts-expect-error
const x2: ExtensionInput<
typeof stringDataRef,
{ singleton: true; optional: false }
> = input;
// @ts-expect-error
const x3: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x4: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: true }
> = input;
unused(x1, x2, x3, x4);
});
it('should create an optional singleton input', () => {
const input = createExtensionInput([stringDataRef, numberDataRef], {
singleton: true,
optional: true,
});
expect(input).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: [stringDataRef, numberDataRef],
config: { singleton: true, optional: true },
});
const x1: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: true; optional: true }
> = input;
// @ts-expect-error
const x2: ExtensionInput<
typeof stringDataRef,
{ singleton: true; optional: true }
> = input;
// @ts-expect-error
const x3: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x4: ExtensionInput<
typeof stringDataRef | typeof numberDataRef,
{ singleton: false; optional: true }
> = input;
unused(x1, x2, x3, x4);
});
it('should not allow duplicate data refs', () => {
expect(() =>
createExtensionInput([stringDataRef, stringDataRef], { singleton: true }),
).toThrow("ExtensionInput may not have duplicate data refs: 'str'");
});
describe('old api', () => {
it('should create a regular input', () => {
const input = createExtensionInput({
str: stringDataRef,
num: numberDataRef,
});
expect(input).toEqual({
$$type: '@backstage/ExtensionInput',
extensionData: { str: stringDataRef, num: numberDataRef },
config: { singleton: false, optional: false },
});
const x1: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x2: LegacyExtensionInput<
{ str: typeof numberDataRef; num: typeof stringDataRef }, // switched
{ singleton: false; optional: false }
> = input;
// @ts-expect-error
const x3: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: true; optional: false }
> = input;
// @ts-expect-error
const x4: LegacyExtensionInput<
{ str: typeof stringDataRef; num: typeof numberDataRef },
{ singleton: false; optional: true }
> = input;
unused(x1, x2, x3, x4);
});
});
});
@@ -15,31 +15,104 @@
*/
import { AnyExtensionDataMap } from './createExtension';
import { ExtensionDataRef } from './createExtensionDataRef';
/** @public */
export interface ExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
TConfig extends { singleton: boolean; optional: boolean },
> {
$$type: '@backstage/ExtensionInput';
extensionData: TExtensionData;
extensionData: Array<TExtensionData>;
config: TConfig;
}
/** @public */
/**
* @public
* @deprecated This type will be removed. Use `ExtensionInput` instead.
*/
export interface LegacyExtensionInput<
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton: boolean; optional: boolean },
> {
$$type: '@backstage/ExtensionInput';
extensionData: TExtensionDataMap;
config: TConfig;
}
/**
* @public
* @deprecated Use the following form instead: `createExtensionInput([dataRef1, dataRef2])`
*/
export function createExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: TExtensionData,
extensionData: TExtensionDataMap,
config?: TConfig,
): ExtensionInput<
TExtensionData,
): LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
> {
>;
/** @public */
export function createExtensionInput<
UExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: Array<UExtensionData>,
config?: TConfig,
): ExtensionInput<
UExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
export function createExtensionInput<
TExtensionData extends ExtensionDataRef<unknown, string, { optional?: true }>,
TExtensionDataMap extends AnyExtensionDataMap,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: Array<TExtensionData> | TExtensionDataMap,
config?: TConfig,
):
| LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>
| ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
> {
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(extensionData)) {
const seen = new Set();
const duplicates = [];
for (const dataRef of extensionData) {
if (seen.has(dataRef.id)) {
duplicates.push(dataRef.id);
} else {
seen.add(dataRef.id);
}
}
if (duplicates.length > 0) {
throw new Error(
`ExtensionInput may not have duplicate data refs: '${duplicates.join(
"', '",
)}'`,
);
}
}
}
return {
$$type: '@backstage/ExtensionInput',
extensionData,
@@ -51,5 +124,19 @@ export function createExtensionInput<
? true
: false,
},
};
} as
| LegacyExtensionInput<
TExtensionDataMap,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>
| ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
}
@@ -17,21 +17,27 @@
export { coreExtensionData } from './coreExtensionData';
export {
createExtension,
type ExtensionDataContainer,
type ExtensionDefinition,
type CreateExtensionOptions,
type ExtensionDataValues,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
type LegacyCreateExtensionOptions,
type AnyExtensionInputMap,
type AnyExtensionDataMap,
} from './createExtension';
export {
createExtensionInput,
type ExtensionInput,
type LegacyExtensionInput,
} from './createExtensionInput';
export {
createExtensionDataRef,
type AnyExtensionDataRef,
type ExtensionDataRef,
type ExtensionDataRefToValue,
type ExtensionDataValue,
type ConfigurableExtensionDataRef,
} from './createExtensionDataRef';
export { createPlugin, type PluginOptions } from './createPlugin';
@@ -17,14 +17,54 @@
import { ExtensionDefinition } from './createExtension';
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
const baseDef = {
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
attachTo: { id: '', input: '' },
disabled: false,
};
describe('resolveExtensionDefinition', () => {
const baseDef = {
$$type: '@backstage/ExtensionDefinition',
version: 'v2',
attachTo: { id: '', input: '' },
disabled: false,
};
it.each([
[{ namespace: 'ns' }, 'ns'],
[{ namespace: 'n' }, 'n'],
[{ namespace: 'ns', name: 'n' }, 'ns/n'],
[{ kind: 'k', namespace: 'ns' }, 'k:ns'],
[{ kind: 'k', namespace: 'ns', name: 'n' }, 'k:ns/n'],
])(`should resolve extension IDs %s`, (definition, expected) => {
const resolved = resolveExtensionDefinition({
...baseDef,
...definition,
} as ExtensionDefinition<unknown>);
expect(resolved.id).toBe(expected);
expect(String(resolved)).toBe(`Extension{id=${expected}}`);
});
it('should fail to resolve extension ID without namespace', () => {
expect(() =>
resolveExtensionDefinition({
...baseDef,
kind: 'k',
} as ExtensionDefinition<unknown>),
).toThrow(
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=k namespace=undefined name=undefined',
);
expect(() =>
resolveExtensionDefinition(baseDef as ExtensionDefinition<unknown>),
).toThrow(
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined',
);
});
});
describe('old resolveExtensionDefinition', () => {
const baseDef = {
$$type: '@backstage/ExtensionDefinition',
version: 'v1',
attachTo: { id: '', input: '' },
disabled: false,
};
it.each([
[{ namespace: 'ns' }, 'ns'],
[{ namespace: 'n' }, 'n'],
@@ -24,6 +24,11 @@ import {
toInternalExtensionDefinition,
} from './createExtension';
import { PortableSchema } from '../schema';
import { ExtensionInput } from './createExtensionInput';
import {
AnyExtensionDataRef,
ExtensionDataValue,
} from './createExtensionDataRef';
/** @public */
export interface Extension<TConfig, TConfigInput = TConfig> {
@@ -35,17 +40,42 @@ export interface Extension<TConfig, TConfigInput = TConfig> {
}
/** @internal */
export interface InternalExtension<TConfig, TConfigInput>
extends Extension<TConfig, TConfigInput> {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(options: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
}
export type InternalExtension<TConfig, TConfigInput> = Extension<
TConfig,
TConfigInput
> &
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(options: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
}
| {
readonly version: 'v2';
readonly inputs: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
readonly output: Array<AnyExtensionDataRef>;
factory(options: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtension<TConfig, TConfigInput>(
@@ -57,10 +87,9 @@ export function toInternalExtension<TConfig, TConfigInput>(
`Invalid extension instance, bad type '${internal.$$type}'`,
);
}
if (internal.version !== 'v1') {
throw new Error(
`Invalid extension instance, bad version '${internal.version}'`,
);
const version = internal.version;
if (version !== 'v1' && version !== 'v2') {
throw new Error(`Invalid extension instance, bad version '${version}'`);
}
return internal;
}
@@ -87,10 +116,10 @@ export function resolveExtensionDefinition<TConfig, TConfigInput>(
return {
...rest,
$$type: '@backstage/Extension',
version: 'v1',
version: internalDefinition.version,
id,
toString() {
return `Extension{id=${id}}`;
},
} as Extension<TConfig, TConfigInput>;
} as InternalExtension<TConfig, TConfigInput>;
}
@@ -19,6 +19,7 @@ import { MemoryRouter, Link } from 'react-router-dom';
import { RenderResult, render } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDataValue,
ExtensionDefinition,
IconComponent,
RouteRef,
@@ -93,21 +94,48 @@ export class ExtensionTester {
options?: { config?: TConfigInput },
): ExtensionTester {
const tester = new ExtensionTester();
const { output, factory, ...rest } = toInternalExtensionDefinition(subject);
const internal = toInternalExtensionDefinition(subject);
// attaching to app/routes to render as index route
const extension = createExtension({
...rest,
attachTo: { id: 'app/routes', input: 'routes' },
output: {
...output,
path: coreExtensionData.routePath,
},
factory: params => ({
...factory(params),
path: '/',
}),
});
tester.add(extension, options as TConfigInput & {});
if (internal.version === 'v1') {
tester.add(
createExtension({
...internal,
attachTo: { id: 'app/routes', input: 'routes' },
output: {
...internal.output,
path: coreExtensionData.routePath,
},
factory: params => ({
...internal.factory(params as any),
path: '/',
}),
}),
options as TConfigInput & {},
);
} else if (internal.version === 'v2') {
tester.add(
createExtension({
...internal,
attachTo: { id: 'app/routes', input: 'routes' },
output: internal.output.find(
ref => ref.id === coreExtensionData.routePath.id,
)
? internal.output
: [...internal.output, coreExtensionData.routePath],
factory: params => {
const parentOutput = Array.from(
internal.factory(params) as Iterable<
ExtensionDataValue<any, any>
>,
).filter(val => val.id !== coreExtensionData.routePath.id);
return [...parentOutput, coreExtensionData.routePath('/')];
},
}),
options as TConfigInput & {},
);
}
return tester;
}
+3 -3
View File
@@ -18,18 +18,18 @@ import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const catalogExtensionData: {
entityContentTitle: ConfigurableExtensionDataRef<
'catalog.entity-content-title',
string,
'catalog.entity-content-title',
{}
>;
entityFilterFunction: ConfigurableExtensionDataRef<
'catalog.entity-filter-function',
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{}
>;
entityFilterExpression: ConfigurableExtensionDataRef<
'catalog.entity-filter-expression',
string,
'catalog.entity-filter-expression',
{}
>;
};
+1 -1
View File
@@ -12,8 +12,8 @@ export default _default;
// @alpha (undocumented)
export const titleExtensionDataRef: ConfigurableExtensionDataRef<
'title',
string,
'title',
{}
>;
+1 -1
View File
@@ -31,11 +31,11 @@ export function createSearchResultListItemExtension<
export namespace createSearchResultListItemExtension {
const // (undocumented)
itemDataRef: ConfigurableExtensionDataRef<
'search.search-result-list-item.item',
{
predicate?: SearchResultItemExtensionPredicate | undefined;
component: SearchResultItemExtensionComponent;
},
'search.search-result-list-item.item',
{}
>;
}