Merge pull request #19768 from backstage/mob/plugin-connection
frontend-*-api: introduce ExtensionBoundary and pass source to extension factories
This commit is contained in:
@@ -41,13 +41,11 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
|
||||
|
||||
// pull in default extension instance from discovered packages
|
||||
// apply config to adjust default extension instances and add more
|
||||
const extensionParams = mergeExtensionParameters(
|
||||
[
|
||||
...options.plugins.flatMap(plugin => plugin.extensions),
|
||||
...builtinExtensions,
|
||||
],
|
||||
readAppExtensionParameters(appConfig),
|
||||
);
|
||||
const extensionParams = mergeExtensionParameters({
|
||||
sources: options.plugins,
|
||||
builtinExtensions,
|
||||
parameters: readAppExtensionParameters(appConfig),
|
||||
});
|
||||
|
||||
// TODO: validate the config of all extension instances
|
||||
// We do it at this point to ensure that merging (if any) of config has already happened
|
||||
@@ -96,6 +94,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
|
||||
|
||||
return createExtensionInstance({
|
||||
extension: instanceParams.extension,
|
||||
source: instanceParams.source,
|
||||
config: instanceParams.config,
|
||||
attachments,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Extension } from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin, Extension } from '@backstage/frontend-plugin-api';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
/** @internal */
|
||||
@@ -28,9 +28,10 @@ export interface ExtensionInstance {
|
||||
export function createExtensionInstance(options: {
|
||||
extension: Extension<unknown>;
|
||||
config: unknown;
|
||||
source?: BackstagePlugin;
|
||||
attachments: Record<string, ExtensionInstance[]>;
|
||||
}): ExtensionInstance {
|
||||
const { extension, config, attachments } = options;
|
||||
const { extension, config, source, attachments } = options;
|
||||
const extensionData = new Map<string, unknown>();
|
||||
|
||||
let parsedConfig: unknown;
|
||||
@@ -44,6 +45,7 @@ export function createExtensionInstance(options: {
|
||||
|
||||
try {
|
||||
extension.factory({
|
||||
source,
|
||||
config: parsedConfig,
|
||||
bind: mapValues(extension.output, ref => {
|
||||
return (value: unknown) => extensionData.set(ref.id, value);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { Extension } from '@backstage/frontend-plugin-api';
|
||||
import { createPlugin, Extension } from '@backstage/frontend-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
expandShorthandExtensionParameters,
|
||||
@@ -33,15 +33,25 @@ function makeExt(id: string, status: 'disabled' | 'enabled' = 'enabled') {
|
||||
|
||||
describe('mergeExtensionParameters', () => {
|
||||
it('should filter out disabled extension instances', () => {
|
||||
expect(mergeExtensionParameters([makeExt('a', 'disabled')], [])).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
sources: [],
|
||||
builtinExtensions: [makeExt('a', 'disabled')],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should pass through extension instances', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
expect(mergeExtensionParameters([a, b], [])).toEqual([
|
||||
expect(
|
||||
mergeExtensionParameters({
|
||||
sources: [],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [],
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: a, at: 'root' },
|
||||
{ extension: b, at: 'root' },
|
||||
]);
|
||||
@@ -50,18 +60,20 @@ describe('mergeExtensionParameters', () => {
|
||||
it('should override attachment points', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const pluginA = createPlugin({ id: 'test', extensions: [a] });
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[a, b],
|
||||
[
|
||||
mergeExtensionParameters({
|
||||
sources: [pluginA],
|
||||
builtinExtensions: [b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
at: 'derp',
|
||||
},
|
||||
],
|
||||
),
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: a, at: 'root' },
|
||||
{ extension: a, at: 'root', source: pluginA },
|
||||
{ extension: b, at: 'derp' },
|
||||
]);
|
||||
});
|
||||
@@ -69,10 +81,12 @@ describe('mergeExtensionParameters', () => {
|
||||
it('should fully override configuration and duplicate', () => {
|
||||
const a = makeExt('a');
|
||||
const b = makeExt('b');
|
||||
const plugin = createPlugin({ id: 'test', extensions: [a, b] });
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[a, b],
|
||||
[
|
||||
mergeExtensionParameters({
|
||||
sources: [plugin],
|
||||
builtinExtensions: [],
|
||||
parameters: [
|
||||
{
|
||||
id: 'a',
|
||||
config: { foo: { bar: 1 } },
|
||||
@@ -86,10 +100,10 @@ describe('mergeExtensionParameters', () => {
|
||||
config: { foo: { qux: 3 } },
|
||||
},
|
||||
],
|
||||
),
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: a, at: 'root', config: { foo: { bar: 1 } } },
|
||||
{ extension: b, at: 'root', config: { foo: { qux: 3 } } },
|
||||
{ extension: a, at: 'root', source: plugin, config: { foo: { bar: 1 } } },
|
||||
{ extension: b, at: 'root', source: plugin, config: { foo: { qux: 3 } } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -97,9 +111,10 @@ describe('mergeExtensionParameters', () => {
|
||||
const a = makeExt('a', 'disabled');
|
||||
const b = makeExt('b', 'disabled');
|
||||
expect(
|
||||
mergeExtensionParameters(
|
||||
[a, b],
|
||||
[
|
||||
mergeExtensionParameters({
|
||||
sources: [createPlugin({ id: 'empty', extensions: [] })],
|
||||
builtinExtensions: [a, b],
|
||||
parameters: [
|
||||
{
|
||||
id: 'b',
|
||||
disabled: false,
|
||||
@@ -109,7 +124,7 @@ describe('mergeExtensionParameters', () => {
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
),
|
||||
}),
|
||||
).toEqual([
|
||||
{ extension: b, at: 'root' },
|
||||
{ extension: a, at: 'root' },
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import { Extension } from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin, Extension } from '@backstage/frontend-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export interface ExtensionParameters {
|
||||
@@ -183,23 +183,41 @@ export function expandShorthandExtensionParameters(
|
||||
|
||||
export interface ExtensionInstanceParameters {
|
||||
extension: Extension<unknown>;
|
||||
source?: BackstagePlugin;
|
||||
at: string;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function mergeExtensionParameters(
|
||||
base: Extension<unknown>[],
|
||||
parameters: Array<ExtensionParameters>,
|
||||
): ExtensionInstanceParameters[] {
|
||||
const overrides = base.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
at: extension.at,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
}));
|
||||
export function mergeExtensionParameters(options: {
|
||||
sources: BackstagePlugin[];
|
||||
builtinExtensions: Extension<unknown>[];
|
||||
parameters: Array<ExtensionParameters>;
|
||||
}): ExtensionInstanceParameters[] {
|
||||
const { sources, builtinExtensions, parameters } = options;
|
||||
|
||||
const overrides = [
|
||||
...sources.flatMap(plugin =>
|
||||
plugin.extensions.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
source: plugin,
|
||||
at: extension.at,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
),
|
||||
...builtinExtensions.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
at: extension.at,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
for (const overrideParam of parameters) {
|
||||
const existingIndex = overrides.findIndex(
|
||||
@@ -234,6 +252,7 @@ export function mergeExtensionParameters(
|
||||
.map(param => ({
|
||||
extension: param.extension,
|
||||
at: param.params.at,
|
||||
source: param.params.source,
|
||||
config: param.params.config,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { AnyApiFactory } from '@backstage/core-plugin-api';
|
||||
import { AnyApiRef } from '@backstage/core-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { ZodTypeDef } from 'zod';
|
||||
@@ -95,6 +97,7 @@ export interface CreateExtensionOptions<
|
||||
disabled?: boolean;
|
||||
// (undocumented)
|
||||
factory(options: {
|
||||
source?: BackstagePlugin;
|
||||
bind: ExtensionDataBind<TData>;
|
||||
config: TConfig;
|
||||
inputs: {
|
||||
@@ -166,6 +169,7 @@ export interface Extension<TConfig> {
|
||||
disabled: boolean;
|
||||
// (undocumented)
|
||||
factory(options: {
|
||||
source?: BackstagePlugin;
|
||||
bind: ExtensionDataBind<AnyExtensionDataMap>;
|
||||
config: TConfig;
|
||||
inputs: Record<string, Array<Record<string, unknown>>>;
|
||||
@@ -183,6 +187,19 @@ export interface Extension<TConfig> {
|
||||
output: AnyExtensionDataMap;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export function ExtensionBoundary(
|
||||
props: ExtensionBoundaryProps,
|
||||
): React_2.JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ExtensionBoundaryProps {
|
||||
// (undocumented)
|
||||
children: ReactNode;
|
||||
// (undocumented)
|
||||
source?: BackstagePlugin;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type ExtensionDataBind<TData extends AnyExtensionDataMap> = {
|
||||
[K in keyof TData]: (value: TData[K]['T']) => void;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 React, { ReactNode } from 'react';
|
||||
import { BackstagePlugin } from '../wiring';
|
||||
|
||||
/** @public */
|
||||
export interface ExtensionBoundaryProps {
|
||||
children: ReactNode;
|
||||
source?: BackstagePlugin;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function ExtensionBoundary(props: ExtensionBoundaryProps) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
ExtensionBoundary,
|
||||
type ExtensionBoundaryProps,
|
||||
} from './ExtensionBoundary';
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ExtensionBoundary } from '../components';
|
||||
import { createSchemaFromZod, PortableSchema } from '../createSchemaFromZod';
|
||||
import {
|
||||
AnyExtensionDataMap,
|
||||
@@ -72,7 +73,7 @@ export function createPageExtension<
|
||||
},
|
||||
inputs: options.inputs,
|
||||
configSchema,
|
||||
factory({ bind, config, inputs }) {
|
||||
factory({ bind, config, inputs, source }) {
|
||||
const LazyComponent = React.lazy(() =>
|
||||
options
|
||||
.component({ config, inputs })
|
||||
@@ -80,9 +81,11 @@ export function createPageExtension<
|
||||
);
|
||||
bind.path(config.path);
|
||||
bind.component(() => (
|
||||
<React.Suspense fallback="...">
|
||||
<LazyComponent />
|
||||
</React.Suspense>
|
||||
<ExtensionBoundary source={source}>
|
||||
<React.Suspense fallback="...">
|
||||
<LazyComponent />
|
||||
</React.Suspense>
|
||||
</ExtensionBoundary>
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
createSchemaFromZod,
|
||||
type PortableSchema,
|
||||
} from './createSchemaFromZod';
|
||||
export * from './components';
|
||||
export * from './extensions';
|
||||
export {
|
||||
coreExtensionData,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { AnyApiFactory } from '@backstage/core-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { PortableSchema } from './createSchemaFromZod';
|
||||
import { BackstagePlugin } from './wiring';
|
||||
|
||||
/** @public */
|
||||
export type ExtensionDataRef<T> = {
|
||||
@@ -64,6 +65,7 @@ export interface CreateExtensionOptions<
|
||||
output: TData;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
factory(options: {
|
||||
source?: BackstagePlugin;
|
||||
bind: ExtensionDataBind<TData>;
|
||||
config: TConfig;
|
||||
inputs: {
|
||||
@@ -84,6 +86,7 @@ export interface Extension<TConfig> {
|
||||
output: AnyExtensionDataMap;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
factory(options: {
|
||||
source?: BackstagePlugin;
|
||||
bind: ExtensionDataBind<AnyExtensionDataMap>;
|
||||
config: TConfig;
|
||||
inputs: Record<string, Array<Record<string, unknown>>>;
|
||||
|
||||
Reference in New Issue
Block a user