app-next: move core API to frontend-{app,plugin}-api

Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Co-authored-by: Philipp Hugenroth <philipph@spotify.com>
Co-authored-by: Camila Belo <camilaibs@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-08-21 14:49:29 +02:00
parent c795a3ae0a
commit fe40e6de46
15 changed files with 676 additions and 372 deletions
+2
View File
@@ -14,6 +14,8 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-adr": "workspace:^",
"@backstage/plugin-airbrake": "workspace:^",
+3 -364
View File
@@ -14,15 +14,10 @@
* limitations under the License.
*/
import {
Router as GraphiQLPage,
graphiqlPlugin as legacyGraphiqlPlugin,
} from '@backstage/plugin-graphiql';
import { graphiqlPlugin as legacyGraphiqlPlugin } from '@backstage/plugin-graphiql';
import { createApp as createLegacyApp } from '@backstage/app-defaults';
import React, { ComponentType } from 'react';
import { BrowserRouter, useRoutes } from 'react-router-dom';
import mapValues from 'lodash/mapValues';
import { Config, ConfigReader } from '@backstage/config';
import { createApp } from '@backstage/frontend-app-api';
import { graphiqlPlugin } from './examples/graphiqlPlugin';
/*
@@ -49,364 +44,8 @@ TODO:
// return ['@backstage/plugin-graphiql'];
// };
interface ExtensionDataRef<T> {
id: string;
T: T;
$$type: 'extension-data';
}
function createExtensionDataRef<T>(id: string) {
return { id, $$type: 'extension-data' } as ExtensionDataRef<T>;
}
const coreExtensionData = {
reactComponent: createExtensionDataRef<ComponentType>('core.reactComponent'),
routePath: createExtensionDataRef<string>('core.routing.path'),
};
type AnyExtensionDataMap = Record<string, ExtensionDataRef<any>>;
type ExtensionDataBind<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: (value: TData[K]['T']) => void;
};
type ExtensionDataValue<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: TData[K]['T'];
};
interface CreateExtensionOptions<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
> {
inputs?: TPoint;
output: TData;
factory(options: {
bind: ExtensionDataBind<TData>;
config?: unknown;
inputs: {
[pointName in keyof TPoint]: ExtensionDataValue<
TPoint[pointName]['extensionData']
>[];
};
}): void;
}
interface Extension {
$$type: 'extension';
inputs: Record<string, { extensionData: AnyExtensionDataMap }>;
output: AnyExtensionDataMap;
factory(options: {
bind: ExtensionDataBind<AnyExtensionDataMap>;
config?: unknown;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
}
function createExtension<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
>(options: CreateExtensionOptions<TData, TPoint>): Extension {
return { ...options, $$type: 'extension', inputs: options.inputs ?? {} };
}
type ExtensionDataId = string;
interface ExtensionInstance {
id: string;
data: Map<ExtensionDataId, unknown>;
$$type: 'extension-instance';
}
function createExtensionInstance(options: {
id: string;
extension: Extension;
config: unknown;
attachments: Record<string, ExtensionInstance[]>;
}): ExtensionInstance {
const { extension, config, attachments } = options;
const extensionData = new Map<ExtensionDataId, unknown>();
extension.factory({
config,
bind: mapValues(extension.output, ref => {
return (value: unknown) => extensionData.set(ref.id, value);
}),
inputs: mapValues(
extension.inputs,
({ extensionData: pointData }, inputName) => {
// TODO: validation
return (attachments[inputName] ?? []).map(attachment =>
mapValues(pointData, ref => attachment.data.get(ref.id)),
);
},
),
});
return { id: options.id, data: extensionData, $$type: 'extension-instance' };
}
interface ExtensionInstanceConfig {
id: string;
at: string;
extension: Extension;
config: unknown;
}
interface BackstagePluginOptions {
id: string;
defaultExtensionInstances?: ExtensionInstanceConfig[];
}
interface BackstagePlugin {
$$type: 'backstage-plugin';
id: string;
defaultExtensionInstances: ExtensionInstanceConfig[];
}
function createPlugin(options: BackstagePluginOptions): BackstagePlugin {
return {
...options,
$$type: 'backstage-plugin',
defaultExtensionInstances: options.defaultExtensionInstances ?? [],
};
}
/* core extensions */
const RouteExtension = createExtension({
inputs: {
routes: {
extensionData: {
path: coreExtensionData.routePath,
component: coreExtensionData.reactComponent,
},
},
},
output: {
component: coreExtensionData.reactComponent,
},
factory({ bind, inputs }) {
const Routes = () => {
const element = useRoutes(
inputs.routes.map(route => ({
path: route.path,
element: <route.component />,
})),
);
return element;
};
bind.component(() => (
<BrowserRouter>
<Routes />
</BrowserRouter>
));
},
});
// Since we'll never merge arrays in config the config reader context
// isn't too much of a help. Fall back to manual config reading logic
// as the Config interface makes it quite hard for us otherwise.
function readAppExtensionConfigs(
rootConfig: Config,
): Partial<ExtensionInstanceConfig>[] {
const arr = rootConfig.getOptional('app.extensions');
if (!Array.isArray(arr)) {
if (arr === undefined) {
return [];
}
// This will throw, and show which part of config had the wrong type
rootConfig.getConfigArray('app.extensions');
return [];
}
return arr.map((value, index) => {
function errorMsg(msg: string, key?: string, prop?: string) {
return `Invalid extension configuration at app.extensions[${index}]${
key ? `[${key}]` : ''
}${prop ? `.${prop}` : ''}, ${msg}`;
}
if (typeof value === 'string') {
return { id: value };
} else if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value)
) {
throw new Error(errorMsg('must be a string or an object'));
}
const keys = Object.keys(value);
if (keys.length !== 1) {
const joinedKeys = `"${keys.join('", "')}"`;
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
}
const key = keys[0];
const obj = value[key];
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
throw new Error(errorMsg('must be an object', key));
}
const at = obj.at;
if (at !== undefined && typeof at !== 'string') {
throw new Error(errorMsg('must be a string', key, 'at'));
}
const extension = obj.extension;
if (extension !== undefined && typeof extension !== 'string') {
throw new Error(errorMsg('must be a string', key, 'extension'));
}
if (extension) {
throw new Error('TODO: implement extension resolution');
}
return { id: key, at, config: obj.config /* validate later */ };
});
}
function createApp(options: { plugins: BackstagePlugin[] }): {
createRoot(): JSX.Element;
} {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionInstanceConfigs = [
...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances),
{
id: 'core.router',
at: 'root/default',
extension: RouteExtension,
config: undefined,
},
];
const appExtensionConfigs = readAppExtensionConfigs(appConfig);
for (const appExtensionConfig of appExtensionConfigs) {
const existingConfig = extensionInstanceConfigs.find(
e => e.id === appExtensionConfig.id,
);
if (existingConfig) {
if (appExtensionConfig.at) {
existingConfig.at = appExtensionConfig.at;
}
if (appExtensionConfig.extension) {
// TODO: do we want to reset config here? it might be completely
// unrelated to the previous one
existingConfig.extension = appExtensionConfig.extension;
}
if (appExtensionConfig.config) {
// TODO: merge config?
existingConfig.config = appExtensionConfig.config;
}
} else if (appExtensionConfig.id) {
const { id, at, extension, config } = appExtensionConfig;
if (!at || !extension) {
throw new Error(`Extension ${appExtensionConfig.id} is incomplete`);
}
extensionInstanceConfigs.push({ id, at, extension, config });
}
}
// Create attachment map so that we can look attachments up during instance creation
const attachmentMap = new Map<
string,
Map<string, ExtensionInstanceConfig[]>
>();
for (const instanceConfig of extensionInstanceConfigs) {
const [extensionId, pointId = 'default'] = instanceConfig.at.split('/');
let pointMap = attachmentMap.get(extensionId);
if (!pointMap) {
pointMap = new Map();
attachmentMap.set(extensionId, pointMap);
}
let instances = pointMap.get(pointId);
if (!instances) {
instances = [];
pointMap.set(pointId, instances);
}
instances.push(instanceConfig);
}
const instances = new Map<string, ExtensionInstance>();
function createInstance(
instanceConfig: ExtensionInstanceConfig,
): ExtensionInstance {
const existingInstance = instances.get(instanceConfig.id);
if (existingInstance) {
return existingInstance;
}
const attachments = Object.fromEntries(
Array.from(attachmentMap.get(instanceConfig.id)?.entries() ?? []).map(
([inputName, attachmentConfigs]) => [
inputName,
attachmentConfigs.map(createInstance),
],
),
);
return createExtensionInstance({
id: instanceConfig.id,
config: instanceConfig.config,
extension: instanceConfig.extension,
attachments,
});
}
const rootConfigs = attachmentMap.get('root')?.get('default') ?? [];
const rootInstances = rootConfigs.map(instanceConfig =>
createInstance(instanceConfig),
);
return {
createRoot() {
const rootComponents = rootInstances.map(
e =>
e.data.get(
coreExtensionData.reactComponent.id,
) as typeof coreExtensionData.reactComponent.T,
);
return (
<>
{rootComponents.map(Component => (
<Component />
))}
</>
);
},
};
}
/* graphiql package */
const GraphiqlPageExtension = createExtension({
output: {
component: coreExtensionData.reactComponent,
path: coreExtensionData.routePath,
},
factory({ bind, config }) {
bind.component(() => {
return <GraphiQLPage />;
});
// TODO stop it. I'm serious
bind.path((config as { path: string }).path);
},
});
const graphiqlPlugin = createPlugin({
id: 'graphiql',
defaultExtensionInstances: [
{
id: 'graphiql.page',
at: 'core.router/routes',
extension: GraphiqlPageExtension,
config: { path: '/graphiql' },
},
],
});
/* app.tsx */
const app = createApp({
@@ -0,0 +1,49 @@
/*
* 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 from 'react';
import {
createExtension,
createPlugin,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { Router as GraphiQLPage } from '@backstage/plugin-graphiql';
export const GraphiqlPageExtension = createExtension({
output: {
component: coreExtensionData.reactComponent,
path: coreExtensionData.routePath,
},
factory({ bind, config }) {
bind.component(() => {
return <GraphiQLPage />;
});
// TODO: In need of schemas and type safety
bind.path((config as { path: string }).path);
},
});
export const graphiqlPlugin = createPlugin({
id: 'graphiql',
defaultExtensionInstances: [
{
id: 'graphiql.page',
at: 'core.router/routes',
extension: GraphiqlPageExtension,
config: { path: '/graphiql' },
},
],
});
+8 -1
View File
@@ -3,5 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// (No @packageDocumentation comment for this package)
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
// @public (undocumented)
export function createApp(options: { plugins: BackstagePlugin[] }): {
createRoot(): JSX.Element;
};
```
+10 -1
View File
@@ -28,5 +28,14 @@
},
"files": [
"dist"
]
],
"dependencies": {
"@backstage/config": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-graphiql": "workspace:^"
},
"peerDependencies": {
"react": "*",
"react-router-dom": "*"
}
}
+204
View File
@@ -0,0 +1,204 @@
/*
* 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 from 'react';
import { Config, ConfigReader } from '@backstage/config';
import {
ExtensionInstanceConfig,
BackstagePlugin,
ExtensionInstance,
createExtensionInstance,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { RouteExtension } from './extensions/RouteExtension';
// Since we'll never merge arrays in config the config reader context
// isn't too much of a help. Fall back to manual config reading logic
// as the Config interface makes it quite hard for us otherwise.
function readAppExtensionConfigs(
rootConfig: Config,
): Partial<ExtensionInstanceConfig>[] {
const arr = rootConfig.getOptional('app.extensions');
if (!Array.isArray(arr)) {
if (arr === undefined) {
return [];
}
// This will throw, and show which part of config had the wrong type
rootConfig.getConfigArray('app.extensions');
return [];
}
return arr.map((value, index) => {
function errorMsg(msg: string, key?: string, prop?: string) {
return `Invalid extension configuration at app.extensions[${index}]${
key ? `[${key}]` : ''
}${prop ? `.${prop}` : ''}, ${msg}`;
}
if (typeof value === 'string') {
return { id: value };
} else if (
typeof value !== 'object' ||
value === null ||
Array.isArray(value)
) {
throw new Error(errorMsg('must be a string or an object'));
}
const keys = Object.keys(value);
if (keys.length !== 1) {
const joinedKeys = `"${keys.join('", "')}"`;
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
}
const key = keys[0];
const obj = value[key];
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
throw new Error(errorMsg('must be an object', key));
}
const at = obj.at;
if (at !== undefined && typeof at !== 'string') {
throw new Error(errorMsg('must be a string', key, 'at'));
}
const extension = obj.extension;
if (extension !== undefined && typeof extension !== 'string') {
throw new Error(errorMsg('must be a string', key, 'extension'));
}
if (extension) {
throw new Error('TODO: implement extension resolution');
}
return { id: key, at, config: obj.config /* validate later */ };
});
}
/** @public */
export function createApp(options: { plugins: BackstagePlugin[] }): {
createRoot(): JSX.Element;
} {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
const extensionInstanceConfigs = [
...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances),
{
id: 'core.router',
at: 'root/default',
extension: RouteExtension,
config: undefined,
},
];
const appExtensionConfigs = readAppExtensionConfigs(appConfig);
for (const appExtensionConfig of appExtensionConfigs) {
const existingConfig = extensionInstanceConfigs.find(
e => e.id === appExtensionConfig.id,
);
if (existingConfig) {
if (appExtensionConfig.at) {
existingConfig.at = appExtensionConfig.at;
}
if (appExtensionConfig.extension) {
// TODO: do we want to reset config here? it might be completely
// unrelated to the previous one
existingConfig.extension = appExtensionConfig.extension;
}
if (appExtensionConfig.config) {
// TODO: merge config?
existingConfig.config = appExtensionConfig.config;
}
} else if (appExtensionConfig.id) {
const { id, at, extension, config } = appExtensionConfig;
if (!at || !extension) {
throw new Error(`Extension ${appExtensionConfig.id} is incomplete`);
}
extensionInstanceConfigs.push({ id, at, extension, config });
}
}
// Create attachment map so that we can look attachments up during instance creation
const attachmentMap = new Map<
string,
Map<string, ExtensionInstanceConfig[]>
>();
for (const instanceConfig of extensionInstanceConfigs) {
const [extensionId, pointId = 'default'] = instanceConfig.at.split('/');
let pointMap = attachmentMap.get(extensionId);
if (!pointMap) {
pointMap = new Map();
attachmentMap.set(extensionId, pointMap);
}
let instances = pointMap.get(pointId);
if (!instances) {
instances = [];
pointMap.set(pointId, instances);
}
instances.push(instanceConfig);
}
const instances = new Map<string, ExtensionInstance>();
function createInstance(
instanceConfig: ExtensionInstanceConfig,
): ExtensionInstance {
const existingInstance = instances.get(instanceConfig.id);
if (existingInstance) {
return existingInstance;
}
const attachments = Object.fromEntries(
Array.from(attachmentMap.get(instanceConfig.id)?.entries() ?? []).map(
([inputName, attachmentConfigs]) => [
inputName,
attachmentConfigs.map(createInstance),
],
),
);
return createExtensionInstance({
id: instanceConfig.id,
config: instanceConfig.config,
extension: instanceConfig.extension,
attachments,
});
}
const rootConfigs = attachmentMap.get('root')?.get('default') ?? [];
const rootInstances = rootConfigs.map(instanceConfig =>
createInstance(instanceConfig),
);
return {
createRoot() {
const rootComponents = rootInstances.map(
e =>
e.data.get(
coreExtensionData.reactComponent.id,
) as typeof coreExtensionData.reactComponent.T,
);
return (
<>
{rootComponents.map(Component => (
<Component />
))}
</>
);
},
};
}
@@ -0,0 +1,53 @@
/*
* 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 from 'react';
import {
createExtension,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { BrowserRouter, useRoutes } from 'react-router-dom';
export const RouteExtension = createExtension({
inputs: {
routes: {
extensionData: {
path: coreExtensionData.routePath,
component: coreExtensionData.reactComponent,
},
},
},
output: {
component: coreExtensionData.reactComponent,
},
factory({ bind, inputs }) {
const Routes = () => {
const element = useRoutes(
inputs.routes.map(route => ({
path: route.path,
element: <route.component />,
})),
);
return element;
};
bind.component(() => (
<BrowserRouter>
<Routes />
</BrowserRouter>
));
},
});
+8 -1
View File
@@ -13,4 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
/**
* TODO
*
* @packageDocumentation
*/
export { createApp } from './createApp';
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
+139 -1
View File
@@ -3,5 +3,143 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// (No @packageDocumentation comment for this package)
import { ComponentType } from 'react';
// @public (undocumented)
export type AnyExtensionDataMap = Record<string, ExtensionDataRef<any>>;
// @public (undocumented)
export interface BackstagePlugin {
// (undocumented)
$$type: 'backstage-plugin';
// (undocumented)
defaultExtensionInstances: ExtensionInstanceConfig[];
// (undocumented)
id: string;
}
// @public (undocumented)
export interface BackstagePluginOptions {
// (undocumented)
defaultExtensionInstances?: ExtensionInstanceConfig[];
// (undocumented)
id: string;
}
// @public (undocumented)
export const coreExtensionData: {
reactComponent: ExtensionDataRef<ComponentType<{}>>;
routePath: ExtensionDataRef<string>;
};
// @public (undocumented)
export function createExtension<
TData extends AnyExtensionDataMap,
TPoint extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
>(options: CreateExtensionOptions<TData, TPoint>): Extension;
// @public (undocumented)
export function createExtensionInstance(options: {
id: string;
extension: Extension;
config: unknown;
attachments: Record<string, ExtensionInstance[]>;
}): ExtensionInstance;
// @public (undocumented)
export interface CreateExtensionOptions<
TData extends AnyExtensionDataMap,
TPoint extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
> {
// (undocumented)
factory(options: {
bind: ExtensionDataBind<TData>;
config?: unknown;
inputs: {
[pointName in keyof TPoint]: ExtensionDataValue<
TPoint[pointName]['extensionData']
>[];
};
}): void;
// (undocumented)
inputs?: TPoint;
// (undocumented)
output: TData;
}
// @public (undocumented)
export function createPlugin(options: BackstagePluginOptions): BackstagePlugin;
// @public (undocumented)
export interface Extension {
// (undocumented)
$$type: 'extension';
// (undocumented)
factory(options: {
bind: ExtensionDataBind<AnyExtensionDataMap>;
config?: unknown;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
// (undocumented)
inputs: Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>;
// (undocumented)
output: AnyExtensionDataMap;
}
// @public (undocumented)
export type ExtensionDataBind<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: (value: TData[K]['T']) => void;
};
// @public (undocumented)
export type ExtensionDataId = string;
// @public (undocumented)
export type ExtensionDataRef<T> = {
id: string;
T: T;
$$type: 'extension-data';
};
// @public (undocumented)
export type ExtensionDataValue<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: TData[K]['T'];
};
// @public (undocumented)
export interface ExtensionInstance {
// (undocumented)
$$type: 'extension-instance';
// (undocumented)
data: Map<ExtensionDataId, unknown>;
// (undocumented)
id: string;
}
// @public (undocumented)
export interface ExtensionInstanceConfig {
// (undocumented)
at: string;
// (undocumented)
config: unknown;
// (undocumented)
extension: Extension;
// (undocumented)
id: string;
}
```
+7 -1
View File
@@ -28,5 +28,11 @@
},
"files": [
"dist"
]
],
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"dependencies": {
"lodash": "^4.17.21"
}
}
+24 -1
View File
@@ -13,4 +13,27 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
/**
* TODO
*
* @packageDocumentation
*/
export {
createExtension,
createExtensionInstance,
coreExtensionData,
createPlugin,
type ExtensionInstanceConfig,
type BackstagePlugin,
type ExtensionInstance,
type Extension,
type AnyExtensionDataMap,
type BackstagePluginOptions,
type CreateExtensionOptions,
type ExtensionDataBind,
type ExtensionDataId,
type ExtensionDataRef,
type ExtensionDataValue,
} from './types';
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
+154
View File
@@ -0,0 +1,154 @@
/*
* 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 mapValues from 'lodash/mapValues';
import { ComponentType } from 'react';
/** @public */
export type ExtensionDataRef<T> = {
id: string;
T: T;
$$type: 'extension-data';
};
/** @public */
export function createExtensionDataRef<T>(id: string): ExtensionDataRef<T> {
return { id, $$type: 'extension-data' } as ExtensionDataRef<T>;
}
/** @public */
export const coreExtensionData = {
reactComponent: createExtensionDataRef<ComponentType>('core.reactComponent'),
routePath: createExtensionDataRef<string>('core.routing.path'),
};
/** @public */
export type AnyExtensionDataMap = Record<string, ExtensionDataRef<any>>;
/** @public */
export type ExtensionDataBind<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: (value: TData[K]['T']) => void;
};
/** @public */
export type ExtensionDataValue<TData extends AnyExtensionDataMap> = {
[K in keyof TData]: TData[K]['T'];
};
/** @public */
export interface CreateExtensionOptions<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
> {
inputs?: TPoint;
output: TData;
factory(options: {
bind: ExtensionDataBind<TData>;
config?: unknown;
inputs: {
[pointName in keyof TPoint]: ExtensionDataValue<
TPoint[pointName]['extensionData']
>[];
};
}): void;
}
/** @public */
export interface Extension {
$$type: 'extension';
inputs: Record<string, { extensionData: AnyExtensionDataMap }>;
output: AnyExtensionDataMap;
factory(options: {
bind: ExtensionDataBind<AnyExtensionDataMap>;
config?: unknown;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
}
/** @public */
export function createExtension<
TData extends AnyExtensionDataMap,
TPoint extends Record<string, { extensionData: AnyExtensionDataMap }>,
>(options: CreateExtensionOptions<TData, TPoint>): Extension {
return { ...options, $$type: 'extension', inputs: options.inputs ?? {} };
}
/** @public */
export type ExtensionDataId = string;
/** @public */
export interface ExtensionInstance {
id: string;
data: Map<ExtensionDataId, unknown>;
$$type: 'extension-instance';
}
/** @public */
export function createExtensionInstance(options: {
id: string;
extension: Extension;
config: unknown;
attachments: Record<string, ExtensionInstance[]>;
}): ExtensionInstance {
const { extension, config, attachments } = options;
const extensionData = new Map<ExtensionDataId, unknown>();
extension.factory({
config,
bind: mapValues(extension.output, ref => {
return (value: unknown) => extensionData.set(ref.id, value);
}),
inputs: mapValues(
extension.inputs,
({ extensionData: pointData }, inputName) => {
// TODO: validation
return (attachments[inputName] ?? []).map(attachment =>
mapValues(pointData, ref => attachment.data.get(ref.id)),
);
},
),
});
return { id: options.id, data: extensionData, $$type: 'extension-instance' };
}
/** @public */
export interface ExtensionInstanceConfig {
id: string;
at: string;
extension: Extension;
config: unknown;
}
/** @public */
export interface BackstagePluginOptions {
id: string;
defaultExtensionInstances?: ExtensionInstanceConfig[];
}
/** @public */
export interface BackstagePlugin {
$$type: 'backstage-plugin';
id: string;
defaultExtensionInstances: ExtensionInstanceConfig[];
}
/** @public */
export function createPlugin(options: BackstagePluginOptions): BackstagePlugin {
return {
...options,
$$type: 'backstage-plugin',
defaultExtensionInstances: options.defaultExtensionInstances ?? [],
};
}
+13 -2
View File
@@ -4060,21 +4060,30 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/frontend-app-api@workspace:packages/frontend-app-api":
"@backstage/frontend-app-api@workspace:^, @backstage/frontend-app-api@workspace:packages/frontend-app-api":
version: 0.0.0-use.local
resolution: "@backstage/frontend-app-api@workspace:packages/frontend-app-api"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-graphiql": "workspace:^"
"@testing-library/jest-dom": ^5.10.1
peerDependencies:
react: "*"
react-router-dom: "*"
languageName: unknown
linkType: soft
"@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api":
"@backstage/frontend-plugin-api@workspace:^, @backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api":
version: 0.0.0-use.local
resolution: "@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api"
dependencies:
"@backstage/cli": "workspace:^"
"@testing-library/jest-dom": ^5.10.1
lodash: ^4.17.21
peerDependencies:
react: ^16.13.1 || ^17.0.0
languageName: unknown
linkType: soft
@@ -25202,6 +25211,8 @@ __metadata:
"@backstage/core-app-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-adr": "workspace:^"
"@backstage/plugin-airbrake": "workspace:^"