From fe40e6de46cd76e3588e6d7e12c374bc602a74f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 14:49:29 +0200 Subject: [PATCH] app-next: move core API to frontend-{app,plugin}-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Vincenzo Scamporlino Co-authored-by: Philipp Hugenroth Co-authored-by: Camila Belo Signed-off-by: Patrik Oldsberg --- packages/app-next/package.json | 2 + packages/app-next/src/App.tsx | 367 +----------------- .../app-next/src/examples/graphiqlPlugin.tsx | 49 +++ packages/frontend-app-api/api-report.md | 9 +- packages/frontend-app-api/package.json | 11 +- packages/frontend-app-api/src/createApp.tsx | 204 ++++++++++ .../src/extensions/RouteExtension.tsx | 53 +++ packages/frontend-app-api/src/index.ts | 9 +- packages/frontend-app-api/src/setupTests.ts | 1 + packages/frontend-plugin-api/api-report.md | 140 ++++++- packages/frontend-plugin-api/package.json | 8 +- packages/frontend-plugin-api/src/index.ts | 25 +- .../frontend-plugin-api/src/setupTests.ts | 1 + packages/frontend-plugin-api/src/types.ts | 154 ++++++++ yarn.lock | 15 +- 15 files changed, 676 insertions(+), 372 deletions(-) create mode 100644 packages/app-next/src/examples/graphiqlPlugin.tsx create mode 100644 packages/frontend-app-api/src/createApp.tsx create mode 100644 packages/frontend-app-api/src/extensions/RouteExtension.tsx create mode 100644 packages/frontend-plugin-api/src/types.ts diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 776e611c60..d2642c2fde 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -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:^", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index c21d13c088..b875fb3927 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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 { - id: string; - T: T; - $$type: 'extension-data'; -} - -function createExtensionDataRef(id: string) { - return { id, $$type: 'extension-data' } as ExtensionDataRef; -} - -const coreExtensionData = { - reactComponent: createExtensionDataRef('core.reactComponent'), - routePath: createExtensionDataRef('core.routing.path'), -}; - -type AnyExtensionDataMap = Record>; - -type ExtensionDataBind = { - [K in keyof TData]: (value: TData[K]['T']) => void; -}; - -type ExtensionDataValue = { - [K in keyof TData]: TData[K]['T']; -}; - -interface CreateExtensionOptions< - TData extends AnyExtensionDataMap, - TPoint extends Record, -> { - inputs?: TPoint; - output: TData; - factory(options: { - bind: ExtensionDataBind; - config?: unknown; - inputs: { - [pointName in keyof TPoint]: ExtensionDataValue< - TPoint[pointName]['extensionData'] - >[]; - }; - }): void; -} - -interface Extension { - $$type: 'extension'; - inputs: Record; - output: AnyExtensionDataMap; - factory(options: { - bind: ExtensionDataBind; - config?: unknown; - inputs: Record>>; - }): void; -} - -function createExtension< - TData extends AnyExtensionDataMap, - TPoint extends Record, ->(options: CreateExtensionOptions): Extension { - return { ...options, $$type: 'extension', inputs: options.inputs ?? {} }; -} - -type ExtensionDataId = string; - -interface ExtensionInstance { - id: string; - data: Map; - $$type: 'extension-instance'; -} - -function createExtensionInstance(options: { - id: string; - extension: Extension; - config: unknown; - attachments: Record; -}): ExtensionInstance { - const { extension, config, attachments } = options; - const extensionData = new Map(); - 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: , - })), - ); - - return element; - }; - bind.component(() => ( - - - - )); - }, -}); - -// 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[] { - 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 - >(); - 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(); - - 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 => ( - - ))} - - ); - }, - }; -} - /* graphiql package */ -const GraphiqlPageExtension = createExtension({ - output: { - component: coreExtensionData.reactComponent, - path: coreExtensionData.routePath, - }, - factory({ bind, config }) { - bind.component(() => { - return ; - }); - // 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({ diff --git a/packages/app-next/src/examples/graphiqlPlugin.tsx b/packages/app-next/src/examples/graphiqlPlugin.tsx new file mode 100644 index 0000000000..87f2bb242c --- /dev/null +++ b/packages/app-next/src/examples/graphiqlPlugin.tsx @@ -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 ; + }); + // 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' }, + }, + ], +}); diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index d2a4d89246..bf7db4451b 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -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) +/// + +import { BackstagePlugin } from '@backstage/frontend-plugin-api'; + +// @public (undocumented) +export function createApp(options: { plugins: BackstagePlugin[] }): { + createRoot(): JSX.Element; +}; ``` diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 9e10dfe0b6..ce68871133 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -28,5 +28,14 @@ }, "files": [ "dist" - ] + ], + "dependencies": { + "@backstage/config": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-graphiql": "workspace:^" + }, + "peerDependencies": { + "react": "*", + "react-router-dom": "*" + } } diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx new file mode 100644 index 0000000000..9c06ed2902 --- /dev/null +++ b/packages/frontend-app-api/src/createApp.tsx @@ -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[] { + 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 + >(); + 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(); + + 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 => ( + + ))} + + ); + }, + }; +} diff --git a/packages/frontend-app-api/src/extensions/RouteExtension.tsx b/packages/frontend-app-api/src/extensions/RouteExtension.tsx new file mode 100644 index 0000000000..ef7e34ea30 --- /dev/null +++ b/packages/frontend-app-api/src/extensions/RouteExtension.tsx @@ -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: , + })), + ); + + return element; + }; + bind.component(() => ( + + + + )); + }, +}); diff --git a/packages/frontend-app-api/src/index.ts b/packages/frontend-app-api/src/index.ts index 4b9026cde5..45fe9413ba 100644 --- a/packages/frontend-app-api/src/index.ts +++ b/packages/frontend-app-api/src/index.ts @@ -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'; diff --git a/packages/frontend-app-api/src/setupTests.ts b/packages/frontend-app-api/src/setupTests.ts index 865308e634..c30f1d15cb 100644 --- a/packages/frontend-app-api/src/setupTests.ts +++ b/packages/frontend-app-api/src/setupTests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import '@testing-library/jest-dom'; diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 2f86355ea7..660662599b 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -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>; + +// @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>; + routePath: ExtensionDataRef; +}; + +// @public (undocumented) +export function createExtension< + TData extends AnyExtensionDataMap, + TPoint extends Record< + string, + { + extensionData: AnyExtensionDataMap; + } + >, +>(options: CreateExtensionOptions): Extension; + +// @public (undocumented) +export function createExtensionInstance(options: { + id: string; + extension: Extension; + config: unknown; + attachments: Record; +}): ExtensionInstance; + +// @public (undocumented) +export interface CreateExtensionOptions< + TData extends AnyExtensionDataMap, + TPoint extends Record< + string, + { + extensionData: AnyExtensionDataMap; + } + >, +> { + // (undocumented) + factory(options: { + bind: ExtensionDataBind; + 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; + config?: unknown; + inputs: Record>>; + }): void; + // (undocumented) + inputs: Record< + string, + { + extensionData: AnyExtensionDataMap; + } + >; + // (undocumented) + output: AnyExtensionDataMap; +} + +// @public (undocumented) +export type ExtensionDataBind = { + [K in keyof TData]: (value: TData[K]['T']) => void; +}; + +// @public (undocumented) +export type ExtensionDataId = string; + +// @public (undocumented) +export type ExtensionDataRef = { + id: string; + T: T; + $$type: 'extension-data'; +}; + +// @public (undocumented) +export type ExtensionDataValue = { + [K in keyof TData]: TData[K]['T']; +}; + +// @public (undocumented) +export interface ExtensionInstance { + // (undocumented) + $$type: 'extension-instance'; + // (undocumented) + data: Map; + // (undocumented) + id: string; +} + +// @public (undocumented) +export interface ExtensionInstanceConfig { + // (undocumented) + at: string; + // (undocumented) + config: unknown; + // (undocumented) + extension: Extension; + // (undocumented) + id: string; +} ``` diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index fc49e443b1..731e66fed0 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -28,5 +28,11 @@ }, "files": [ "dist" - ] + ], + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "dependencies": { + "lodash": "^4.17.21" + } } diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index 4b9026cde5..dbc5e130e7 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -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'; diff --git a/packages/frontend-plugin-api/src/setupTests.ts b/packages/frontend-plugin-api/src/setupTests.ts index 865308e634..c30f1d15cb 100644 --- a/packages/frontend-plugin-api/src/setupTests.ts +++ b/packages/frontend-plugin-api/src/setupTests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import '@testing-library/jest-dom'; diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts new file mode 100644 index 0000000000..cb5bce0af9 --- /dev/null +++ b/packages/frontend-plugin-api/src/types.ts @@ -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 = { + id: string; + T: T; + $$type: 'extension-data'; +}; + +/** @public */ +export function createExtensionDataRef(id: string): ExtensionDataRef { + return { id, $$type: 'extension-data' } as ExtensionDataRef; +} + +/** @public */ +export const coreExtensionData = { + reactComponent: createExtensionDataRef('core.reactComponent'), + routePath: createExtensionDataRef('core.routing.path'), +}; + +/** @public */ +export type AnyExtensionDataMap = Record>; + +/** @public */ +export type ExtensionDataBind = { + [K in keyof TData]: (value: TData[K]['T']) => void; +}; + +/** @public */ +export type ExtensionDataValue = { + [K in keyof TData]: TData[K]['T']; +}; + +/** @public */ +export interface CreateExtensionOptions< + TData extends AnyExtensionDataMap, + TPoint extends Record, +> { + inputs?: TPoint; + output: TData; + factory(options: { + bind: ExtensionDataBind; + config?: unknown; + inputs: { + [pointName in keyof TPoint]: ExtensionDataValue< + TPoint[pointName]['extensionData'] + >[]; + }; + }): void; +} + +/** @public */ +export interface Extension { + $$type: 'extension'; + inputs: Record; + output: AnyExtensionDataMap; + factory(options: { + bind: ExtensionDataBind; + config?: unknown; + inputs: Record>>; + }): void; +} + +/** @public */ +export function createExtension< + TData extends AnyExtensionDataMap, + TPoint extends Record, +>(options: CreateExtensionOptions): Extension { + return { ...options, $$type: 'extension', inputs: options.inputs ?? {} }; +} + +/** @public */ +export type ExtensionDataId = string; + +/** @public */ +export interface ExtensionInstance { + id: string; + data: Map; + $$type: 'extension-instance'; +} + +/** @public */ +export function createExtensionInstance(options: { + id: string; + extension: Extension; + config: unknown; + attachments: Record; +}): ExtensionInstance { + const { extension, config, attachments } = options; + const extensionData = new Map(); + 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 ?? [], + }; +} diff --git a/yarn.lock b/yarn.lock index 7cf063b616..6e4b036475 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"