frontend-plugin-api: return output from extension factories instead

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-27 13:51:02 +02:00
parent 6d041afa0c
commit 77f009b35d
28 changed files with 313 additions and 173 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
Extensions now return their output from the factory function rather than calling `bind(...)`.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/frontend-app-api': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-graphiql': patch
'@backstage/plugin-search-react': patch
---
Internal updates to match changes in the experimental `@backstage/frontend-plugin-api`.
+2 -2
View File
@@ -79,8 +79,8 @@ const homePageExtension = createExtension({
children: coreExtensionData.reactElement,
title: titleExtensionDataRef,
},
factory({ bind }) {
bind({ children: homePage, title: 'just a title' });
factory() {
return { children: homePage, title: 'just a title' };
},
});
@@ -113,18 +113,18 @@ export function convertLegacyApp(
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
factory({ inputs }) {
// Clone the root element, this replaces the FlatRoutes declared in the app with out content input
bind({
return {
element: React.cloneElement(rootEl, undefined, inputs.content.element),
});
};
},
});
const CoreNavOverride = createExtension({
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
output: {},
factory() {},
factory: () => ({}),
disabled: true,
});
@@ -40,9 +40,9 @@ export const Core = createExtension({
output: {
root: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
bind({
factory({ inputs }) {
return {
root: inputs.root.element,
});
};
},
});
@@ -42,14 +42,14 @@ export const CoreLayout = createExtension({
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
bind({
factory({ inputs }) {
return {
element: (
<SidebarPage>
{inputs.nav.element}
{inputs.content.element}
</SidebarPage>
),
});
};
},
});
@@ -82,8 +82,8 @@ export const CoreNav = createExtension({
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
bind({
factory({ inputs }) {
return {
element: (
<Sidebar>
<SidebarLogo />
@@ -93,6 +93,6 @@ export const CoreNav = createExtension({
))}
</Sidebar>
),
});
};
},
});
@@ -35,7 +35,7 @@ export const CoreRoutes = createExtension({
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
factory({ inputs }) {
const Routes = () => {
const element = useRoutes(
inputs.routes.map(route => ({
@@ -46,8 +46,8 @@ export const CoreRoutes = createExtension({
return element;
};
bind({
return {
element: <Routes />,
});
};
},
});
@@ -26,7 +26,7 @@ const extBase = {
id: 'test',
attachTo: { id: 'core', input: 'root' },
output: {},
factory() {},
factory: () => ({}),
};
describe('createAppGraph', () => {
@@ -62,7 +62,7 @@ describe('createAppGraph', () => {
attachTo: { id: 'core.routes', input: 'route' },
inputs: {},
output: {},
factory() {},
factory: () => ({}),
}),
],
}),
@@ -45,8 +45,8 @@ const simpleExtension = createExtension({
other: z.number().optional(),
}),
),
factory({ bind, config }) {
bind({ test: config.output, other: config.other });
factory({ config }) {
return { test: config.output, other: config.other };
},
});
@@ -114,8 +114,8 @@ describe('instantiateAppNodeTree', () => {
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
@@ -158,8 +158,8 @@ describe('instantiateAppNodeTree', () => {
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
@@ -264,8 +264,8 @@ describe('createAppNodeInstance', () => {
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
factory({ inputs }) {
return { inputMirror: inputs };
},
}),
),
@@ -326,8 +326,8 @@ describe('createAppNodeInstance', () => {
test1: testDataRef,
test2: testDataRef,
},
factory({ bind }) {
bind({ test1: 'test', test2: 'test2' });
factory({}) {
return { test1: 'test', test2: 'test2' };
},
}),
),
@@ -348,8 +348,8 @@ describe('createAppNodeInstance', () => {
output: {
test: testDataRef,
},
factory({ bind }) {
bind({ nonexistent: 'test' } as any);
factory({}) {
return { nonexistent: 'test' } as any;
},
}),
),
@@ -376,7 +376,7 @@ describe('createAppNodeInstance', () => {
),
},
output: {},
factory() {},
factory: () => ({}),
}),
),
attachments: new Map(),
@@ -417,7 +417,7 @@ describe('createAppNodeInstance', () => {
}),
},
output: {},
factory() {},
factory: () => ({}),
}),
),
}),
@@ -447,7 +447,7 @@ describe('createAppNodeInstance', () => {
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {},
factory: () => ({}),
}),
),
}),
@@ -481,7 +481,7 @@ describe('createAppNodeInstance', () => {
),
},
output: {},
factory() {},
factory: () => ({}),
}),
),
}),
@@ -515,7 +515,7 @@ describe('createAppNodeInstance', () => {
),
},
output: {},
factory() {},
factory: () => ({}),
}),
),
}),
@@ -543,7 +543,7 @@ describe('createAppNodeInstance', () => {
),
},
output: {},
factory() {},
factory: () => ({}),
}),
),
}),
@@ -113,26 +113,25 @@ export function createAppNodeInstance(options: {
}
try {
extension.factory({
const namedOutputs = extension.factory({
source,
config: parsedConfig,
bind: namedOutputs => {
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.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);
}
},
inputs: resolveInputs(extension.inputs, attachments),
});
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.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);
}
} catch (e) {
throw new Error(
`Failed to instantiate extension '${id}'${
@@ -21,7 +21,7 @@ const extBaseConfig = {
id: 'test',
attachTo: { id: 'nonexistent', input: 'nonexistent' },
output: {},
factory() {},
factory: () => ({}),
};
const extension = createExtension(extBaseConfig);
@@ -62,12 +62,12 @@ function createTestExtension(options: {
element: coreExtensionData.reactElement,
}),
},
factory({ bind }) {
bind({
factory() {
return {
path: options.path,
routeRef: options.routeRef,
element: React.createElement('div'),
});
};
},
});
}
+2 -4
View File
@@ -170,10 +170,9 @@ export interface CreateExtensionOptions<
// (undocumented)
factory(options: {
source?: BackstagePlugin;
bind(values: Expand<ExtensionDataValues<TOutput>>): void;
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}): void;
}): Expand<ExtensionDataValues<TOutput>>;
// (undocumented)
id: string;
// (undocumented)
@@ -313,13 +312,12 @@ export interface Extension<TConfig> {
// (undocumented)
factory(options: {
source?: BackstagePlugin;
bind(values: ExtensionInputValues<any>): void;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): void;
}): ExtensionDataValues<any>;
// (undocumented)
id: string;
// (undocumented)
@@ -66,8 +66,8 @@ const wrapInBoundaryExtension = (element: JSX.Element) => {
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
},
factory({ bind, source }) {
bind({
factory({ source }) {
return {
routeRef,
path: '/',
element: (
@@ -75,7 +75,7 @@ const wrapInBoundaryExtension = (element: JSX.Element) => {
{element}
</ExtensionBoundary>
),
});
};
},
});
};
@@ -58,12 +58,11 @@ export function createApiExtension<
output: {
api: coreExtensionData.apiFactory,
},
factory({ bind, config, inputs }) {
factory({ config, inputs }) {
if (typeof factory === 'function') {
bind({ api: factory({ config, inputs }) });
} else {
bind({ api: factory });
return { api: factory({ config, inputs }) };
}
return { api: factory };
},
});
}
@@ -41,14 +41,12 @@ export function createNavItemExtension(options: {
output: {
navTarget: coreExtensionData.navTarget,
},
factory: ({ bind, config }) => {
bind({
navTarget: {
title: config.title,
icon,
routeRef,
},
});
},
factory: ({ config }) => ({
navTarget: {
title: config.title,
icon,
routeRef,
},
}),
});
}
@@ -20,7 +20,6 @@ import { useAnalytics } from '@backstage/core-plugin-api';
import { waitFor } from '@testing-library/react';
import { PortableSchema } from '../schema';
import {
ExtensionInputValues,
coreExtensionData,
createExtensionInput,
createPlugin,
@@ -127,16 +126,14 @@ describe('createPageExtension', () => {
loader: async () => <div>Component</div>,
});
extension.factory({
bind: (values: ExtensionInputValues<any>) =>
renderWithEffects(
wrapInTestApp(values.element as unknown as JSX.Element),
),
const output = extension.factory({
source: createPlugin({ id: 'plugin ' }),
config: { path: '/' },
inputs: {},
});
renderWithEffects(wrapInTestApp(output.element as unknown as JSX.Element));
await waitFor(() =>
expect(captureEvent).toHaveBeenCalledWith(
'_ROUTABLE-EXTENSION-RENDERED',
@@ -75,14 +75,14 @@ export function createPageExtension<
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
},
factory({ bind, config, inputs, source }) {
factory({ config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
return {
path: config.path,
routeRef: options.routeRef,
element: (
@@ -90,7 +90,7 @@ export function createPageExtension<
<ExtensionComponent />
</ExtensionBoundary>
),
});
};
},
});
}
@@ -25,8 +25,6 @@ export function createThemeExtension(theme: AppTheme) {
output: {
theme: coreExtensionData.theme,
},
factory({ bind }) {
bind({ theme });
},
factory: () => ({ theme }),
});
}
@@ -24,71 +24,211 @@ function unused(..._any: any[]) {}
describe('createExtension', () => {
it('should create an extension with a simple output', () => {
const extension = createExtension({
const baseConfig = {
id: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
},
factory({ bind }) {
bind({
};
const extension = createExtension({
...baseConfig,
factory() {
return {
foo: 'bar',
});
bind({
// @ts-expect-error
foo: 3,
});
bind({
// @ts-expect-error
bar: 'bar',
});
// @ts-expect-error
bind({});
// @ts-expect-error
bind();
// @ts-expect-error
bind('bar');
};
},
});
expect(extension.id).toBe('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.
createExtension({
...baseConfig,
factory: () => ({
// @ts-expect-error
foo: 3,
}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({
bar: 'bar',
}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
undefined,
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
'bar',
});
// When declared as a function with a block the TypeScript error will instead
// be tied to the factory function declaration itself, but the error messages
// is still helpful and points to part of the return type that is problematic.
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {
foo: 3,
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {
bar: 'bar',
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory() {
return 'bar';
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {
foo: 3,
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {
bar: 'bar',
};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return {};
},
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {
return 'bar';
},
});
});
it('should create an extension with a some optional output', () => {
const extension = createExtension({
const baseConfig = {
id: 'test',
attachTo: { id: 'root', input: 'default' },
output: {
foo: stringData,
bar: stringData.optional(),
},
factory({ bind }) {
bind({
foo: 'bar',
});
bind({
foo: 'bar',
bar: 'baz',
});
bind({
// @ts-expect-error
foo: 3,
});
bind({
foo: 'bar',
// @ts-expect-error
bar: 3,
});
// @ts-expect-error
bind({ bar: 'bar' });
// @ts-expect-error
bind({});
// @ts-expect-error
bind();
// @ts-expect-error
bind('bar');
},
};
const extension = createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
}),
});
expect(extension.id).toBe('test');
createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
bar: 'baz',
}),
});
createExtension({
...baseConfig,
factory: () => ({
// @ts-expect-error
foo: 3,
}),
});
createExtension({
...baseConfig,
factory: () => ({
foo: 'bar',
// @ts-expect-error
bar: 3,
}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({ bar: 'bar' }),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
({}),
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
undefined,
});
createExtension({
...baseConfig,
// @ts-expect-error
factory: () => {},
});
createExtension({
...baseConfig,
factory: () =>
// @ts-expect-error
'bar',
});
});
it('should create an extension with input', () => {
@@ -110,7 +250,7 @@ describe('createExtension', () => {
output: {
foo: stringData,
},
factory({ bind, inputs }) {
factory({ inputs }) {
const a1: string = inputs.mixed?.[0].required;
// @ts-expect-error
const a2: number = inputs.mixed?.[0].required;
@@ -141,9 +281,9 @@ describe('createExtension', () => {
const d4: number | undefined = inputs.onlyOptional?.[0].optional;
unused(d1, d2, d3, d4);
bind({
return {
foo: 'bar',
});
};
},
});
expect(extension.id).toBe('test');
@@ -81,10 +81,9 @@ export interface CreateExtensionOptions<
configSchema?: PortableSchema<TConfig>;
factory(options: {
source?: BackstagePlugin;
bind(values: Expand<ExtensionDataValues<TOutput>>): void;
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}): void;
}): Expand<ExtensionDataValues<TOutput>>;
}
/** @public */
@@ -98,13 +97,12 @@ export interface Extension<TConfig> {
configSchema?: PortableSchema<TConfig>;
factory(options: {
source?: BackstagePlugin;
bind(values: ExtensionInputValues<any>): void;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): void;
}): ExtensionDataValues<any>;
}
/** @public */
@@ -120,12 +118,11 @@ export function createExtension<
disabled: options.disabled ?? false,
$$type: '@backstage/Extension',
inputs: options.inputs ?? {},
factory({ bind, config, inputs }) {
factory({ inputs, ...rest }) {
// TODO: Simplify this, but TS wouldn't infer the input type for some reason
return options.factory({
bind,
config,
inputs: inputs as Expand<ExtensionInputValues<TInputs>>,
...rest,
});
},
};
@@ -39,7 +39,7 @@ describe('createExtensionOverrides', () => {
id: 'a',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory() {},
factory: () => ({}),
}),
],
}),
@@ -72,7 +72,7 @@ describe('createExtensionOverrides', () => {
id: 'a',
attachTo: { id: 'core', input: 'apis' },
output: {},
factory() {},
factory: () => ({}),
}),
],
});
@@ -34,8 +34,8 @@ const TechRadarPage = createExtension({
output: {
name: nameExtensionDataRef,
},
factory({ bind }) {
bind({ name: 'TechRadar' });
factory() {
return { name: 'TechRadar' };
},
});
@@ -48,8 +48,8 @@ const CatalogPage = createExtension({
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('Catalog') }),
),
factory({ bind, config }) {
bind({ name: config.name });
factory({ config }) {
return { name: config.name };
},
});
@@ -62,8 +62,8 @@ const TechDocsAddon = createExtension({
configSchema: createSchemaFromZod(z =>
z.object({ name: z.string().default('TechDocsAddon') }),
),
factory({ bind, config }) {
bind({ name: config.name });
factory({ config }) {
return { name: config.name };
},
});
@@ -78,8 +78,8 @@ const TechDocsPage = createExtension({
output: {
name: nameExtensionDataRef,
},
factory({ bind, inputs }) {
bind({ name: `TechDocs-${inputs.addons.map(n => n.name).join('-')}` });
factory({ inputs }) {
return { name: `TechDocs-${inputs.addons.map(n => n.name).join('-')}` };
},
});
@@ -94,12 +94,12 @@ const outputExtension = createExtension({
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
bind({
factory({ inputs }) {
return {
element: React.createElement('span', {}, [
`Names: ${inputs.names.map(n => n.name).join(', ')}`,
]),
});
};
},
});
+6 -6
View File
@@ -113,21 +113,21 @@ export function createEntityCardExtension<
.optional(),
}),
),
factory({ bind, config, inputs, source }) {
factory({ config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ inputs })
.then(element => ({ default: () => element })),
);
bind({
return {
element: (
<ExtensionBoundary id={id} source={source}>
<ExtensionComponent />
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
});
};
},
});
}
@@ -179,14 +179,14 @@ export function createEntityContentExtension<
.optional(),
}),
),
factory({ bind, config, inputs, source }) {
factory({ config, inputs, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ inputs })
.then(element => ({ default: () => element })),
);
bind({
return {
path: config.path,
title: config.title,
routeRef: options.routeRef,
@@ -196,7 +196,7 @@ export function createEntityContentExtension<
</ExtensionBoundary>
),
filter: buildFilter(config, options.filter),
});
};
},
});
}
@@ -43,20 +43,20 @@ export function createCatalogFilterExtension<
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, config, source }) {
factory({ config, source }) {
const ExtensionComponent = lazy(() =>
options
.loader({ config })
.then(element => ({ default: () => element })),
);
bind({
return {
element: (
<ExtensionBoundary id={id} source={source}>
<ExtensionComponent />
</ExtensionBoundary>
),
});
};
},
});
}
+3 -3
View File
@@ -88,10 +88,10 @@ export function createEndpointExtension<TConfig extends {}>(options: {
output: {
endpoint: endpointDataRef,
},
factory({ bind, config }) {
bind({
factory({ config }) {
return {
endpoint: options.factory({ config }).endpoint,
});
};
},
});
}
+3 -3
View File
@@ -107,14 +107,14 @@ export function createSearchResultListItemExtension<
output: {
item: searchResultItemExtensionData,
},
factory({ bind, config, source }) {
factory({ config, source }) {
const ExtensionComponent = lazy(() =>
options
.component({ config })
.then(component => ({ default: component })),
) as unknown as SearchResultItemExtensionComponent;
bind({
return {
item: {
predicate: options.predicate,
component: props => (
@@ -129,7 +129,7 @@ export function createSearchResultListItemExtension<
</ExtensionBoundary>
),
},
});
};
},
});
}