frontend-plugin-api: wrap resolved extension inputs in an object

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Philipp Hugenroth <philipph@spotify.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-11-26 10:48:12 +01:00
parent 0e1a53db1b
commit 8f5d6c1fbf
29 changed files with 166 additions and 111 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-app-api': patch
'@backstage/core-compat-api': patch
---
Updates to match the new extension input wrapping.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-user-settings': patch
'@backstage/plugin-graphiql': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-search': patch
'@backstage/plugin-home': patch
---
Updates to the `/alpha` exports to match the extension input wrapping change.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
Extension inputs are now wrapped into an additional object when passed to the extension factory, with the previous values being available at the `output` property. The `ExtensionInputValues` type has also been replaced by `ResolvedExtensionInputs`.
@@ -117,7 +117,11 @@ export function convertLegacyApp(
factory({ inputs }) {
// Clone the root element, this replaces the FlatRoutes declared in the app with out content input
return {
element: React.cloneElement(rootEl, undefined, inputs.content.element),
element: React.cloneElement(
rootEl,
undefined,
inputs.content.output.element,
),
};
},
});
@@ -45,7 +45,7 @@ export const Core = createExtension({
},
factory({ inputs }) {
return {
root: inputs.root.element,
root: inputs.root.output.element,
};
},
});
@@ -47,8 +47,8 @@ export const CoreLayout = createExtension({
return {
element: (
<SidebarPage>
{inputs.nav.element}
{inputs.content.element}
{inputs.nav.output.element}
{inputs.content.output.element}
</SidebarPage>
),
};
@@ -99,10 +99,10 @@ export const CoreNav = createExtension({
return {
element: (
<Sidebar>
<SidebarLogo {...inputs.logos?.elements} />
<SidebarLogo {...inputs.logos?.output.elements} />
<SidebarDivider />
{inputs.items.map((item, index) => (
<SidebarNavItem {...item.target} key={index} />
<SidebarNavItem {...item.output.target} key={index} />
))}
</Sidebar>
),
@@ -59,8 +59,8 @@ export const CoreRouter = createExtension({
factory({ inputs }) {
return {
element: (
<AppRouter SignInPageComponent={inputs.signInPage?.component}>
{inputs.children.element}
<AppRouter SignInPageComponent={inputs.signInPage?.output.component}>
{inputs.children.output.element}
</AppRouter>
),
};
@@ -48,8 +48,8 @@ export const CoreRoutes = createExtension({
const element = useRoutes([
...inputs.routes.map(route => ({
path: `${route.path}/*`,
element: route.element,
path: `${route.output.path}/*`,
element: route.output.element,
})),
{
path: '*',
@@ -153,7 +153,7 @@ describe('instantiateAppNodeTree', () => {
expect(tree.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
expect(tree.root.instance?.getData(inputMirrorDataRef)).toEqual({
test: [{ test: 'test' }],
test: [{ extensionId: 'child-node', output: { test: 'test' } }],
});
// Multiple calls should have no effect
@@ -296,9 +296,18 @@ describe('createAppNodeInstance', () => {
expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]);
expect(instance.getData(inputMirrorDataRef)).toEqual({
optionalSingletonPresent: { test: 'optionalSingletonPresent' },
singleton: { test: 'singleton', other: 2 },
many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
optionalSingletonPresent: {
extensionId: 'core/test',
output: { test: 'optionalSingletonPresent' },
},
singleton: {
extensionId: 'core/test',
output: { test: 'singleton', other: 2 },
},
many: [
{ extensionId: 'core/test', output: { test: 'many1' } },
{ extensionId: 'core/test', output: { test: 'many2', other: 3 } },
],
});
});
@@ -18,6 +18,7 @@ import {
AnyExtensionDataMap,
AnyExtensionInputMap,
ExtensionDataRef,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
@@ -45,7 +46,7 @@ function resolveInputData(
function resolveInputs(
inputMap: AnyExtensionInputMap,
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>,
) {
): ResolvedExtensionInputs<AnyExtensionInputMap> {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
@@ -84,13 +85,21 @@ function resolveInputs(
}
throw Error(`input '${inputName}' is required but was not received`);
}
return resolveInputData(input.extensionData, attachedNodes[0], inputName);
return {
extensionId: attachedNodes[0].id,
output: resolveInputData(
input.extensionData,
attachedNodes[0],
inputName,
),
};
}
return attachedNodes.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
return attachedNodes.map(attachment => ({
extensionId: attachment.id,
output: resolveInputData(input.extensionData, attachment, inputName),
}));
}) as ResolvedExtensionInputs<AnyExtensionInputMap>;
}
/** @internal */
@@ -135,7 +144,7 @@ export function createAppNodeInstance(options: {
} catch (e) {
throw new Error(
`Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e.stack}`
}`,
);
}
+30 -29
View File
@@ -382,7 +382,7 @@ export function createApiExtension<
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => AnyApiFactory;
}
| {
@@ -413,13 +413,13 @@ export function createComponentExtension<
| {
lazy: (values: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<TRef['T']>;
}
| {
sync: (values: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => TRef['T'];
};
}): ExtensionDefinition<TConfig>;
@@ -475,7 +475,7 @@ export interface CreateExtensionOptions<
factory(options: {
node: AppNode;
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
inputs?: TInputs;
@@ -556,7 +556,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
},
): ExtensionDefinition<TConfig>;
@@ -610,7 +610,7 @@ export function createSignInPageExtension<
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): ExtensionDefinition<TConfig>;
@@ -657,10 +657,7 @@ export interface Extension<TConfig> {
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
// (undocumented)
id: string;
@@ -730,10 +727,7 @@ export interface ExtensionDefinition<TConfig> {
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
// (undocumented)
inputs: AnyExtensionInputMap;
@@ -763,21 +757,6 @@ export interface ExtensionInput<
extensionData: TExtensionData;
}
// @public
export type ExtensionInputValues<
TInputs extends {
[name in string]: ExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>
: Expand<
ExtensionDataValues<TInputs[InputName]['extensionData']> | undefined
>;
};
// @public (undocumented)
export interface ExtensionOverrides {
// (undocumented)
@@ -906,6 +885,28 @@ export { ProfileInfo };
export { ProfileInfoApi };
// @public
export type ResolvedExtensionInput<TExtensionData extends AnyExtensionDataMap> =
{
extensionId: string;
output: ExtensionDataValues<TExtensionData>;
};
// @public
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>
: Expand<
ResolvedExtensionInput<TInputs[InputName]['extensionData']> | undefined
>;
};
// @public
export type RouteFunc<TParams extends AnyRouteRefParams> = (
...[params]: TParams extends undefined
@@ -17,7 +17,7 @@
import { AnyApiFactory, AnyApiRef } from '@backstage/core-plugin-api';
import { PortableSchema } from '../schema';
import {
ExtensionInputValues,
ResolvedExtensionInputs,
createExtension,
coreExtensionData,
} from '../wiring';
@@ -34,7 +34,7 @@ export function createApiExtension<
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => AnyApiFactory;
}
| {
@@ -17,7 +17,7 @@
import React, { lazy } from 'react';
import {
AnyExtensionInputMap,
ExtensionInputValues,
ResolvedExtensionInputs,
coreExtensionData,
createExtension,
} from '../wiring';
@@ -40,13 +40,13 @@ export function createComponentExtension<
| {
lazy: (values: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<TRef['T']>;
}
| {
sync: (values: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => TRef['T'];
};
}) {
@@ -20,7 +20,7 @@ import { createSchemaFromZod, PortableSchema } from '../schema';
import {
coreExtensionData,
createExtension,
ExtensionInputValues,
ResolvedExtensionInputs,
AnyExtensionInputMap,
} from '../wiring';
import { RouteRef } from '../routing';
@@ -52,7 +52,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
},
): ExtensionDefinition<TConfig> {
@@ -19,7 +19,7 @@ import { ExtensionBoundary } from '../components';
import { PortableSchema } from '../schema';
import {
createExtension,
ExtensionInputValues,
ResolvedExtensionInputs,
AnyExtensionInputMap,
createExtensionDataRef,
ExtensionDefinition,
@@ -47,7 +47,7 @@ export function createSignInPageExtension<
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): ExtensionDefinition<TConfig> {
return createExtension({
@@ -251,34 +251,34 @@ describe('createExtension', () => {
foo: stringData,
},
factory({ inputs }) {
const a1: string = inputs.mixed?.[0].required;
const a1: string = inputs.mixed?.[0].output.required;
// @ts-expect-error
const a2: number = inputs.mixed?.[0].required;
const a2: number = inputs.mixed?.[0].output.required;
// @ts-expect-error
const a3: any = inputs.mixed?.[0].nonExistent;
const a3: any = inputs.mixed?.[0].output.nonExistent;
unused(a1, a2, a3);
const b1: string | undefined = inputs.mixed?.[0].optional;
const b1: string | undefined = inputs.mixed?.[0].output.optional;
// @ts-expect-error
const b2: string = inputs.mixed?.[0].optional;
const b2: string = inputs.mixed?.[0].output.optional;
// @ts-expect-error
const b3: number = inputs.mixed?.[0].optional;
const b3: number = inputs.mixed?.[0].output.optional;
// @ts-expect-error
const b4: number | undefined = inputs.mixed?.[0].optional;
const b4: number | undefined = inputs.mixed?.[0].output.optional;
unused(b1, b2, b3, b4);
const c1: string = inputs.onlyRequired?.[0].required;
const c1: string = inputs.onlyRequired?.[0].output.required;
// @ts-expect-error
const c2: number = inputs.onlyRequired?.[0].required;
const c2: number = inputs.onlyRequired?.[0].output.required;
unused(c1, c2);
const d1: string | undefined = inputs.onlyOptional?.[0].optional;
const d1: string | undefined = inputs.onlyOptional?.[0].output.optional;
// @ts-expect-error
const d2: string = inputs.onlyOptional?.[0].optional;
const d2: string = inputs.onlyOptional?.[0].output.optional;
// @ts-expect-error
const d3: number = inputs.onlyOptional?.[0].optional;
const d3: number = inputs.onlyOptional?.[0].output.optional;
// @ts-expect-error
const d4: number | undefined = inputs.onlyOptional?.[0].optional;
const d4: number | undefined = inputs.onlyOptional?.[0].output.optional;
unused(d1, d2, d3, d4);
return {
@@ -52,18 +52,28 @@ export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
};
/**
* Converts an extension input map into the matching concrete input values type.
* Convert a single extension input into a matching resolved input.
* @public
*/
export type ExtensionInputValues<
export type ResolvedExtensionInput<TExtensionData extends AnyExtensionDataMap> =
{
extensionId: string;
output: ExtensionDataValues<TExtensionData>;
};
/**
* Converts an extension input map into a matching collection of resolved inputs.
* @public
*/
export type ResolvedExtensionInputs<
TInputs extends { [name in string]: ExtensionInput<any, any> },
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>>
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>
? Expand<ResolvedExtensionInput<TInputs[InputName]['extensionData']>>
: Expand<
ExtensionDataValues<TInputs[InputName]['extensionData']> | undefined
ResolvedExtensionInput<TInputs[InputName]['extensionData']> | undefined
>;
};
@@ -84,7 +94,7 @@ export interface CreateExtensionOptions<
factory(options: {
node: AppNode;
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}): Expand<ExtensionDataValues<TOutput>>;
}
@@ -102,10 +112,7 @@ export interface ExtensionDefinition<TConfig> {
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
}
@@ -121,10 +128,7 @@ export interface Extension<TConfig> {
factory(options: {
node: AppNode;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
inputs: ResolvedExtensionInputs<any>;
}): ExtensionDataValues<any>;
}
@@ -149,7 +153,7 @@ export function createExtension<
factory({ inputs, ...rest }) {
// TODO: Simplify this, but TS wouldn't infer the input type for some reason
return options.factory({
inputs: inputs as Expand<ExtensionInputValues<TInputs>>,
inputs: inputs as Expand<ResolvedExtensionInputs<TInputs>>,
...rest,
});
},
@@ -65,7 +65,9 @@ const Extension3 = createExtension({
name: nameExtensionDataRef,
},
factory({ inputs }) {
return { name: `extension-3:${inputs.addons.map(n => n.name).join('-')}` };
return {
name: `extension-3:${inputs.addons.map(n => n.output.name).join('-')}`,
};
},
});
@@ -111,7 +113,7 @@ const outputExtension = createExtension({
factory({ inputs }) {
return {
element: React.createElement('span', {}, [
`Names: ${inputs.names.map(n => n.name).join(', ')}`,
`Names: ${inputs.names.map(n => n.output.name).join(', ')}`,
]),
};
},
@@ -25,7 +25,8 @@ export {
type ExtensionDefinition,
type CreateExtensionOptions,
type ExtensionDataValues,
type ExtensionInputValues,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
type AnyExtensionInputMap,
type AnyExtensionDataMap,
} from './createExtension';
+3 -3
View File
@@ -9,7 +9,7 @@ import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInputValues } from '@backstage/frontend-plugin-api';
import { ResolvedExtensionInputs } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
@@ -29,7 +29,7 @@ export function createEntityCardExtension<
| typeof entityFilterFunctionExtensionDataRef.T
| typeof entityFilterExpressionExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}): ExtensionDefinition<{
filter?: string | undefined;
@@ -54,7 +54,7 @@ export function createEntityContentExtension<
| typeof entityFilterFunctionExtensionDataRef.T
| typeof entityFilterExpressionExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}): ExtensionDefinition<{
title: string;
+3 -3
View File
@@ -17,7 +17,7 @@
import {
AnyExtensionInputMap,
ExtensionBoundary,
ExtensionInputValues,
ResolvedExtensionInputs,
RouteRef,
coreExtensionData,
createExtension,
@@ -59,7 +59,7 @@ export function createEntityCardExtension<
| typeof entityFilterFunctionExtensionDataRef.T
| typeof entityFilterExpressionExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}) {
return createExtension({
@@ -117,7 +117,7 @@ export function createEntityContentExtension<
| typeof entityFilterFunctionExtensionDataRef.T
| typeof entityFilterExpressionExtensionDataRef.T;
loader: (options: {
inputs: Expand<ExtensionInputValues<TInputs>>;
inputs: Expand<ResolvedExtensionInputs<TInputs>>;
}) => Promise<JSX.Element>;
}) {
return createExtension({
+1 -1
View File
@@ -39,7 +39,7 @@ export const OverviewEntityContent = createEntityContentExtension({
},
loader: async ({ inputs }) =>
import('./EntityOverviewPage').then(m => (
<m.EntityOverviewPage cards={inputs.cards} />
<m.EntityOverviewPage cards={inputs.cards.map(c => c.output)} />
)),
});
+5 -5
View File
@@ -39,7 +39,7 @@ export const CatalogIndexPage = createPageExtension({
},
loader: async ({ inputs }) => {
const { BaseCatalogPage } = await import('../components/CatalogPage');
const filters = inputs.filters.map(filter => filter.element);
const filters = inputs.filters.map(filter => filter.output.element);
return <BaseCatalogPage filters={<>{filters}</>} />;
},
});
@@ -64,11 +64,11 @@ export const CatalogEntityPage = createPageExtension({
<EntityLayout>
{inputs.contents.map(content => (
<EntityLayout.Route
key={content.path}
path={content.path}
title={content.title}
key={content.output.path}
path={content.output.path}
title={content.output.title}
>
{content.element}
{content.output.element}
</EntityLayout.Route>
))}
</EntityLayout>
+1 -1
View File
@@ -66,7 +66,7 @@ export const graphiqlBrowseApi = createApiExtension({
factory({ inputs }) {
return createApiFactory(
graphQlBrowseApiRef,
GraphQLEndpoints.from(inputs.endpoints.map(i => i.endpoint)),
GraphQLEndpoints.from(inputs.endpoints.map(i => i.output.endpoint)),
);
},
});
+2 -2
View File
@@ -51,8 +51,8 @@ const HomepageCompositionRootExtension = createPageExtension({
loader: ({ inputs }) =>
import('./components/').then(m => (
<m.HomepageCompositionRoot
children={inputs.props?.children}
title={inputs.props?.title}
children={inputs.props?.output.children}
title={inputs.props?.output.title}
/>
)),
});
+3 -3
View File
@@ -115,10 +115,10 @@ describe('createSearchResultListItemExtension', () => {
);
const getResultItemComponent = (result: SearchResult) => {
const value = inputs.items.find(({ item }) =>
item?.predicate?.(result),
const value = inputs.items.find(item =>
item?.output.item.predicate?.(result),
);
return value?.item.component ?? DefaultResultItem;
return value?.output.item.component ?? DefaultResultItem;
};
const Component = () => {
+4 -2
View File
@@ -112,8 +112,10 @@ export const SearchPage = createPageExtension({
},
loader: async ({ config, inputs }) => {
const getResultItemComponent = (result: SearchResult) => {
const value = inputs.items.find(({ item }) => item?.predicate?.(result));
return value?.item.component ?? DefaultResultListItem;
const value = inputs.items.find(item =>
item?.output.item.predicate?.(result),
);
return value?.output.item.component ?? DefaultResultListItem;
};
const Component = () => {
+3 -1
View File
@@ -39,7 +39,9 @@ const UserSettingsPage = createPageExtension({
},
loader: ({ inputs }) =>
import('./components/SettingsPage').then(m => (
<m.SettingsPage providerSettings={inputs.providerSettings?.element} />
<m.SettingsPage
providerSettings={inputs.providerSettings?.output.element}
/>
)),
});