From a78ae537aed5988537b3e533b3a920b3e1634f4b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Jun 2023 15:16:34 +0200 Subject: [PATCH 01/22] add app-experiments with initial implementation 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: Patrik Oldsberg Co-authored-by: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-experiments/.eslintrc.js | 1 + packages/app-experiments/README.md | 3 + packages/app-experiments/package.json | 140 ++++++++++++++++++ packages/app-experiments/src/Experiment1.tsx | 146 +++++++++++++++++++ packages/app-experiments/src/index.tsx | 22 +++ packages/app-experiments/src/setupTests.ts | 21 +++ yarn.lock | 107 ++++++++++++++ 7 files changed, 440 insertions(+) create mode 100644 packages/app-experiments/.eslintrc.js create mode 100644 packages/app-experiments/README.md create mode 100644 packages/app-experiments/package.json create mode 100644 packages/app-experiments/src/Experiment1.tsx create mode 100644 packages/app-experiments/src/index.tsx create mode 100644 packages/app-experiments/src/setupTests.ts diff --git a/packages/app-experiments/.eslintrc.js b/packages/app-experiments/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/app-experiments/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/app-experiments/README.md b/packages/app-experiments/README.md new file mode 100644 index 0000000000..a2c7ae1b77 --- /dev/null +++ b/packages/app-experiments/README.md @@ -0,0 +1,3 @@ +# example-app + +This package is an example of a Backstage application. diff --git a/packages/app-experiments/package.json b/packages/app-experiments/package.json new file mode 100644 index 0000000000..aa2cb39f1d --- /dev/null +++ b/packages/app-experiments/package.json @@ -0,0 +1,140 @@ +{ + "name": "app-experiments", + "version": "0.0.0", + "private": true, + "backstage": { + "role": "frontend" + }, + "bundled": true, + "dependencies": { + "@backstage/app-defaults": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/integration-react": "workspace:^", + "@backstage/plugin-adr": "workspace:^", + "@backstage/plugin-airbrake": "workspace:^", + "@backstage/plugin-apache-airflow": "workspace:^", + "@backstage/plugin-api-docs": "workspace:^", + "@backstage/plugin-azure-devops": "workspace:^", + "@backstage/plugin-azure-sites": "workspace:^", + "@backstage/plugin-badges": "workspace:^", + "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-catalog-graph": "workspace:^", + "@backstage/plugin-catalog-import": "workspace:^", + "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-catalog-unprocessed-entities": "workspace:^", + "@backstage/plugin-circleci": "workspace:^", + "@backstage/plugin-cloudbuild": "workspace:^", + "@backstage/plugin-code-coverage": "workspace:^", + "@backstage/plugin-cost-insights": "workspace:^", + "@backstage/plugin-devtools": "workspace:^", + "@backstage/plugin-dynatrace": "workspace:^", + "@backstage/plugin-entity-feedback": "workspace:^", + "@backstage/plugin-explore": "workspace:^", + "@backstage/plugin-gcalendar": "workspace:^", + "@backstage/plugin-gcp-projects": "workspace:^", + "@backstage/plugin-github-actions": "workspace:^", + "@backstage/plugin-gocd": "workspace:^", + "@backstage/plugin-graphiql": "workspace:^", + "@backstage/plugin-home": "workspace:^", + "@backstage/plugin-jenkins": "workspace:^", + "@backstage/plugin-kafka": "workspace:^", + "@backstage/plugin-kubernetes": "workspace:^", + "@backstage/plugin-lighthouse": "workspace:^", + "@backstage/plugin-linguist": "workspace:^", + "@backstage/plugin-linguist-common": "workspace:^", + "@backstage/plugin-microsoft-calendar": "workspace:^", + "@backstage/plugin-newrelic": "workspace:^", + "@backstage/plugin-newrelic-dashboard": "workspace:^", + "@backstage/plugin-octopus-deploy": "workspace:^", + "@backstage/plugin-org": "workspace:^", + "@backstage/plugin-pagerduty": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", + "@backstage/plugin-playlist": "workspace:^", + "@backstage/plugin-puppetdb": "workspace:^", + "@backstage/plugin-rollbar": "workspace:^", + "@backstage/plugin-scaffolder": "workspace:^", + "@backstage/plugin-scaffolder-react": "workspace:^", + "@backstage/plugin-search": "workspace:^", + "@backstage/plugin-search-common": "workspace:^", + "@backstage/plugin-search-react": "workspace:^", + "@backstage/plugin-sentry": "workspace:^", + "@backstage/plugin-shortcuts": "workspace:^", + "@backstage/plugin-stack-overflow": "workspace:^", + "@backstage/plugin-stackstorm": "workspace:^", + "@backstage/plugin-tech-insights": "workspace:^", + "@backstage/plugin-tech-radar": "workspace:^", + "@backstage/plugin-techdocs": "workspace:^", + "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^", + "@backstage/plugin-techdocs-react": "workspace:^", + "@backstage/plugin-todo": "workspace:^", + "@backstage/plugin-user-settings": "workspace:^", + "@backstage/theme": "workspace:^", + "@internal/plugin-catalog-customized": "workspace:^", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.61", + "@octokit/rest": "^19.0.3", + "@oriflame/backstage-plugin-score-card": "^0.7.0", + "@roadiehq/backstage-plugin-buildkite": "^2.0.8", + "@roadiehq/backstage-plugin-github-insights": "^2.0.5", + "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", + "@roadiehq/backstage-plugin-travis-ci": "^2.0.5", + "history": "^5.0.0", + "prop-types": "^15.7.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", + "react-use": "^17.2.4", + "zen-observable": "^0.10.0" + }, + "devDependencies": { + "@backstage/test-utils": "workspace:^", + "@testing-library/cypress": "^9.0.0", + "@testing-library/dom": "^8.0.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jquery": "^3.3.34", + "@types/node": "^16.11.26", + "@types/react": "*", + "@types/react-dom": "*", + "@types/zen-observable": "^0.8.0", + "cross-env": "^7.0.0", + "cypress": "^10.0.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "test": "backstage-cli package test", + "lint": "backstage-cli package lint", + "test:e2e": "start-server-and-test start http://localhost:3000 cy:dev", + "test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "files": [ + "dist" + ], + "license": "Apache-2.0" +} diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx new file mode 100644 index 0000000000..4508d097a3 --- /dev/null +++ b/packages/app-experiments/src/Experiment1.tsx @@ -0,0 +1,146 @@ +/* + * 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, { ComponentType, createContext, useMemo } from 'react'; + +/* + +App structure: + - root + - red + - green + - blue + +*/ +// app.registerExtensionPoint() + +// import { redExtensionPointRef } from 'wherever'; + +type Extension = { + factory(instanceOptions: { config: TInstanceConfig }): ComponentType; +}; + +type ExtensionInstance = { + id: string; + config: TInstanceConfig; + extension: Extension; +}; + +const BackstageAppContext = createContext<{ + extensionInstances: ExtensionInstance[]; +}>({ extensionInstances: [] }); + +const container = { + createExtension(extensionOptions: { + render: (config: TInstanceConfig) => JSX.Element; + }): Extension { + return { + factory(instanceOptions: { config: TInstanceConfig }) { + return () => extensionOptions.render(instanceOptions.config); + }, + }; + }, +}; + +const Box = container.createExtension({ + render: (config: { color: string }) => { + return ( +
+ {config.color} +
+ ); + }, +}); + +const ExtensionInstanceDerp = (props: { id: string }) => { + const { id } = props; + const { extensionInstances } = React.useContext(BackstageAppContext); + + const instance = extensionInstances.find(i => i.id === id); + + if (!instance) { + throw new Error(`No extension instance found with id ${id}`); + } + + const Component = useMemo( + () => instance.extension.factory({ config: instance.config }), + [instance], + ); + return ; +}; + +export function Experiment1() { + // const element = useExtension("red"); + + return ( + +

Experiment 1

+

This is an experiment to see how the app-experiments package works.

+ {/* extension point */} + + + +
+ ); +} + +/* +// graphiql-plugin + + + +// App.tsx + +export default app.createRoot(); +export default app.createRoot(extensionInstanceElement('root')); + +function extensionInstanceElement(id: string): JSX.Element { +} + +app: + extensions: + - root.sidebar: + use: 'CustomSidebar' + + +function CustomSidebar() { + return ( + + {extensionInstanceElement('sidebar.items.derp')} + + + ) +} + +const customSidebar = createComponentExtension({ + component: CustomSidebar +}) + +createApp({ + extensions: [customSidebar] +}) + +*/ diff --git a/packages/app-experiments/src/index.tsx b/packages/app-experiments/src/index.tsx new file mode 100644 index 0000000000..6f0434c15e --- /dev/null +++ b/packages/app-experiments/src/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 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 '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Experiment1 } from './Experiment1'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/app-experiments/src/setupTests.ts b/packages/app-experiments/src/setupTests.ts new file mode 100644 index 0000000000..23fcbe9676 --- /dev/null +++ b/packages/app-experiments/src/setupTests.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 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. + */ + +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index b97d410197..e5c4f02164 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19259,6 +19259,113 @@ __metadata: languageName: node linkType: hard +"app-experiments@workspace:packages/app-experiments": + version: 0.0.0-use.local + resolution: "app-experiments@workspace:packages/app-experiments" + dependencies: + "@backstage/app-defaults": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/integration-react": "workspace:^" + "@backstage/plugin-adr": "workspace:^" + "@backstage/plugin-airbrake": "workspace:^" + "@backstage/plugin-apache-airflow": "workspace:^" + "@backstage/plugin-api-docs": "workspace:^" + "@backstage/plugin-azure-devops": "workspace:^" + "@backstage/plugin-azure-sites": "workspace:^" + "@backstage/plugin-badges": "workspace:^" + "@backstage/plugin-catalog-common": "workspace:^" + "@backstage/plugin-catalog-graph": "workspace:^" + "@backstage/plugin-catalog-import": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-catalog-unprocessed-entities": "workspace:^" + "@backstage/plugin-circleci": "workspace:^" + "@backstage/plugin-cloudbuild": "workspace:^" + "@backstage/plugin-code-coverage": "workspace:^" + "@backstage/plugin-cost-insights": "workspace:^" + "@backstage/plugin-devtools": "workspace:^" + "@backstage/plugin-dynatrace": "workspace:^" + "@backstage/plugin-entity-feedback": "workspace:^" + "@backstage/plugin-explore": "workspace:^" + "@backstage/plugin-gcalendar": "workspace:^" + "@backstage/plugin-gcp-projects": "workspace:^" + "@backstage/plugin-github-actions": "workspace:^" + "@backstage/plugin-gocd": "workspace:^" + "@backstage/plugin-graphiql": "workspace:^" + "@backstage/plugin-home": "workspace:^" + "@backstage/plugin-jenkins": "workspace:^" + "@backstage/plugin-kafka": "workspace:^" + "@backstage/plugin-kubernetes": "workspace:^" + "@backstage/plugin-lighthouse": "workspace:^" + "@backstage/plugin-linguist": "workspace:^" + "@backstage/plugin-linguist-common": "workspace:^" + "@backstage/plugin-microsoft-calendar": "workspace:^" + "@backstage/plugin-newrelic": "workspace:^" + "@backstage/plugin-newrelic-dashboard": "workspace:^" + "@backstage/plugin-octopus-deploy": "workspace:^" + "@backstage/plugin-org": "workspace:^" + "@backstage/plugin-pagerduty": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" + "@backstage/plugin-playlist": "workspace:^" + "@backstage/plugin-puppetdb": "workspace:^" + "@backstage/plugin-rollbar": "workspace:^" + "@backstage/plugin-scaffolder": "workspace:^" + "@backstage/plugin-scaffolder-react": "workspace:^" + "@backstage/plugin-search": "workspace:^" + "@backstage/plugin-search-common": "workspace:^" + "@backstage/plugin-search-react": "workspace:^" + "@backstage/plugin-sentry": "workspace:^" + "@backstage/plugin-shortcuts": "workspace:^" + "@backstage/plugin-stack-overflow": "workspace:^" + "@backstage/plugin-stackstorm": "workspace:^" + "@backstage/plugin-tech-insights": "workspace:^" + "@backstage/plugin-tech-radar": "workspace:^" + "@backstage/plugin-techdocs": "workspace:^" + "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^" + "@backstage/plugin-techdocs-react": "workspace:^" + "@backstage/plugin-todo": "workspace:^" + "@backstage/plugin-user-settings": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@internal/plugin-catalog-customized": "workspace:^" + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.61 + "@octokit/rest": ^19.0.3 + "@oriflame/backstage-plugin-score-card": ^0.7.0 + "@roadiehq/backstage-plugin-buildkite": ^2.0.8 + "@roadiehq/backstage-plugin-github-insights": ^2.0.5 + "@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7 + "@roadiehq/backstage-plugin-travis-ci": ^2.0.5 + "@testing-library/cypress": ^9.0.0 + "@testing-library/dom": ^8.0.0 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/jquery": ^3.3.34 + "@types/node": ^16.11.26 + "@types/react": "*" + "@types/react-dom": "*" + "@types/zen-observable": ^0.8.0 + cross-env: ^7.0.0 + cypress: ^10.0.0 + eslint-plugin-cypress: ^2.10.3 + history: ^5.0.0 + prop-types: ^15.7.2 + react: ^17.0.2 + react-dom: ^17.0.2 + react-router: ^6.3.0 + react-router-dom: ^6.3.0 + react-use: ^17.2.4 + start-server-and-test: ^1.10.11 + zen-observable: ^0.10.0 + languageName: unknown + linkType: soft + "append-field@npm:^1.0.0": version: 1.0.0 resolution: "append-field@npm:1.0.0" From 187734fbba83b082873104a713db631b72d8925d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Jun 2023 15:44:56 +0200 Subject: [PATCH 02/22] app-experiments: add parent mounting 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: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment1.tsx | 130 ++++++++++++++----- 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx index 4508d097a3..c5a4bc660b 100644 --- a/packages/app-experiments/src/Experiment1.tsx +++ b/packages/app-experiments/src/Experiment1.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ComponentType, createContext, useMemo } from 'react'; +import React, { ComponentType, createContext } from 'react'; /* @@ -29,34 +29,83 @@ App structure: // import { redExtensionPointRef } from 'wherever'; -type Extension = { - factory(instanceOptions: { config: TInstanceConfig }): ComponentType; +type Extension = { + factory(instanceOptions: { id: string; config: TInstanceConfig }): TOutput; }; -type ExtensionInstance = { +type ExtensionInstanceConfig = { id: string; config: TInstanceConfig; + // what should point be for root + point?: string; extension: Extension; }; +type ExtensionInstance = { + id: string; + // what should point be for root + point?: string; + output: ComponentType; +}; + const BackstageAppContext = createContext<{ - extensionInstances: ExtensionInstance[]; + extensionInstances: ExtensionInstance[]; }>({ extensionInstances: [] }); const container = { createExtension(extensionOptions: { - render: (config: TInstanceConfig) => JSX.Element; + render: (options: { id: string; config: TInstanceConfig }) => JSX.Element; }): Extension { return { - factory(instanceOptions: { config: TInstanceConfig }) { - return () => extensionOptions.render(instanceOptions.config); + factory(instanceOptions) { + return () => + extensionOptions.render({ + id: instanceOptions.id, + config: instanceOptions.config, + }); }, }; }, }; +const ExtensionInstanceDerp = (props: { id: string }) => { + const { id } = props; + const { extensionInstances } = React.useContext(BackstageAppContext); + + const value = extensionInstances.find(i => i.id === id); + + if (!value) { + throw new Error(`No extension instance found with id ${id}`); + } + + const { output: ComponentInstance } = value; + + return ; +}; + +const ExtensionPointInstance = (props: { id: string }) => { + const { extensionInstances } = React.useContext(BackstageAppContext); + + return ( + <> + {extensionInstances + .filter(i => i.point === props.id) + .map(extensionInstance => ( + + ))} + + ); +}; + +const Container = container.createExtension({ + render: ({ id }) => , +}); + const Box = container.createExtension({ - render: (config: { color: string }) => { + render: ({ config }: { config: { color: string } }) => { return (
{config.color} @@ -65,44 +114,55 @@ const Box = container.createExtension({ }, }); -const ExtensionInstanceDerp = (props: { id: string }) => { - const { id } = props; - const { extensionInstances } = React.useContext(BackstageAppContext); - - const instance = extensionInstances.find(i => i.id === id); - - if (!instance) { - throw new Error(`No extension instance found with id ${id}`); - } - - const Component = useMemo( - () => instance.extension.factory({ config: instance.config }), - [instance], - ); - return ; -}; +function createExtensionInstances( + instanceConfigs: ExtensionInstanceConfig[], +): ExtensionInstance[] { + return instanceConfigs.map(instanceConfig => ({ + id: instanceConfig.id, + point: instanceConfig.point, + output: instanceConfig.extension.factory({ + id: instanceConfig.id, + config: instanceConfig.config, + }), + })); +} export function Experiment1() { // const element = useExtension("red"); + const extensionInstances = createExtensionInstances([ + { id: 'root', config: {}, extension: Container }, + { id: 'nestedRoot', point: 'root', config: {}, extension: Container }, + { + id: 'red', + point: 'nestedRoot', + config: { color: 'red' }, + extension: Box, + }, + { + id: 'green', + point: 'nestedRoot', + config: { color: 'green' }, + extension: Box, + }, + { + id: 'blue', + point: 'nestedRoot', + config: { color: 'blue' }, + extension: Box, + }, + ]); + return (

Experiment 1

This is an experiment to see how the app-experiments package works.

- {/* extension point */} - - - +
); } From 9900b42f07aa20b7d0427c9e3bd9420cc2897636 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 5 Jun 2023 19:25:00 +0200 Subject: [PATCH 03/22] app-experiments: WIP non-react extensions 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: Patrik Oldsberg Co-authored-by: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment1.tsx | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx index c5a4bc660b..b086af215c 100644 --- a/packages/app-experiments/src/Experiment1.tsx +++ b/packages/app-experiments/src/Experiment1.tsx @@ -29,40 +29,39 @@ App structure: // import { redExtensionPointRef } from 'wherever'; -type Extension = { +type Extension = { factory(instanceOptions: { id: string; config: TInstanceConfig }): TOutput; }; -type ExtensionInstanceConfig = { +type ExtensionInstanceConfig = { id: string; config: TInstanceConfig; // what should point be for root point?: string; - extension: Extension; + extension: Extension; }; -type ExtensionInstance = { +type ExtensionInstance = { id: string; // what should point be for root point?: string; - output: ComponentType; + output: TOutput; }; const BackstageAppContext = createContext<{ - extensionInstances: ExtensionInstance[]; + extensionInstances: ExtensionInstance[]; }>({ extensionInstances: [] }); const container = { - createExtension(extensionOptions: { - render: (options: { id: string; config: TInstanceConfig }) => JSX.Element; - }): Extension { + createExtension(extensionOptions: { + render: (options: { id: string; config: TInstanceConfig }) => TOutput; + }): Extension { return { factory(instanceOptions) { - return () => - extensionOptions.render({ - id: instanceOptions.id, - config: instanceOptions.config, - }); + return extensionOptions.render({ + id: instanceOptions.id, + config: instanceOptions.config, + }); }, }; }, @@ -78,7 +77,8 @@ const ExtensionInstanceDerp = (props: { id: string }) => { throw new Error(`No extension instance found with id ${id}`); } - const { output: ComponentInstance } = value; + // TODO: Validation? + const ComponentInstance = value.output as React.ComponentType; return ; }; @@ -115,8 +115,8 @@ const Box = container.createExtension({ }); function createExtensionInstances( - instanceConfigs: ExtensionInstanceConfig[], -): ExtensionInstance[] { + instanceConfigs: ExtensionInstanceConfig[], +): ExtensionInstance[] { return instanceConfigs.map(instanceConfig => ({ id: instanceConfig.id, point: instanceConfig.point, From dee3faec8fd5fb37dfaceded356a7a9cece53691 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Jun 2023 17:08:40 +0200 Subject: [PATCH 04/22] app-experiments: distinct extension points + refs for everything 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: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-experiments/package.json | 4 +- packages/app-experiments/src/Experiment1.tsx | 333 +++++++++++++++---- 2 files changed, 270 insertions(+), 67 deletions(-) diff --git a/packages/app-experiments/package.json b/packages/app-experiments/package.json index aa2cb39f1d..af83b2e0e1 100644 --- a/packages/app-experiments/package.json +++ b/packages/app-experiments/package.json @@ -85,13 +85,15 @@ "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", "@roadiehq/backstage-plugin-travis-ci": "^2.0.5", "history": "^5.0.0", + "lodash": "^4.17.21", "prop-types": "^15.7.2", "react": "^17.0.2", "react-dom": "^17.0.2", "react-router": "^6.3.0", "react-router-dom": "^6.3.0", "react-use": "^17.2.4", - "zen-observable": "^0.10.0" + "zen-observable": "^0.10.0", + "zod": "^3.21.4" }, "devDependencies": { "@backstage/test-utils": "workspace:^", diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx index b086af215c..6453ad1179 100644 --- a/packages/app-experiments/src/Experiment1.tsx +++ b/packages/app-experiments/src/Experiment1.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ -import React, { ComponentType, createContext } from 'react'; +import React, { CSSProperties, ComponentType, createContext } from 'react'; +import { z } from 'zod'; +import mapValues from 'lodash/mapValues'; +import { Typography } from '@material-ui/core'; /* @@ -29,23 +32,56 @@ App structure: // import { redExtensionPointRef } from 'wherever'; -type Extension = { - factory(instanceOptions: { id: string; config: TInstanceConfig }): TOutput; +type Extension = { + factory(instanceOptions: { id: string; config: TInstanceConfig }): TSelf; }; -type ExtensionInstanceConfig = { +type ExtensionOptions< + TInstanceConfig, + TSelf, + TPoints extends { + [name in string]: { + typeRef: ExtensionPointTypeRef; + configSchema: z.ZodObject; + }; + }, +> = { + mountTypeRef: ExtensionPointTypeRef; + points: TPoints; + // points: { + // [name in keyof TPoints]: { + // typeRef: TPoints[name]['typeRef']; + // configSchema: z.ZodObject; + // }; + // }; + factory(instanceOptions: { + id: string; + config: TInstanceConfig; + points: { + [name in keyof TPoints]: ExtensionPointRef; + }; + // points: {[name in keyof TPoints]: TPoints[name] extends ExtensionPointTypeRef ? ExtensionPointRef : never}; + }): TSelf; +}; + +// points: { +// top: { ref: componentExtensionPointTypeRef, configSchema: z.object({}) }, +// bottom: { ref: componentExtensionPointTypeRef, configSchema: z.object({}) }, +// }, + +type ExtensionInstanceConfig = { id: string; config: TInstanceConfig; // what should point be for root - point?: string; - extension: Extension; + mount?: string; + extension: Extension; }; -type ExtensionInstance = { +type ExtensionInstance = { id: string; // what should point be for root - point?: string; - output: TOutput; + mount?: string; + output: TSelf; }; const BackstageAppContext = createContext<{ @@ -53,21 +89,213 @@ const BackstageAppContext = createContext<{ }>({ extensionInstances: [] }); const container = { - createExtension(extensionOptions: { - render: (options: { id: string; config: TInstanceConfig }) => TOutput; - }): Extension { + createExtension< + TInstanceConfig, + TSelf, + TPoints extends { + [name in string]: { + typeRef: ExtensionPointTypeRef; + configSchema: z.ZodObject; + }; + }, + >( + extensionOptions: ExtensionOptions, + ): Extension { return { factory(instanceOptions) { - return extensionOptions.render({ + return extensionOptions.factory({ id: instanceOptions.id, config: instanceOptions.config, + points: mapValues(extensionOptions.points, (point, name) => + createExtensionPointRef(name, point.typeRef), + ), }); }, }; }, }; -const ExtensionInstanceDerp = (props: { id: string }) => { +function useExtensionInstanceChildren( + id: string, + point: ExtensionPointRef, +): T[] { + const { extensionInstances } = React.useContext(BackstageAppContext); + + return extensionInstances + .filter(i => i.mount === `${id}/${point.name}`) + .map(extensionInstance => extensionInstance.output as T); +} + +const ExtensionInstanceChildren = (props: { + id: string; + point: ExtensionPointRef; +}) => { + const outputs = useExtensionInstanceChildren(props.id, props.point); + + return ( + <> + {outputs.map(Component => ( + + ))} + + ); +}; + +export const coreExtensionPointTypes = { + component: createExtensionPointTypeRef(), + styles: createExtensionPointTypeRef(), +}; + +const Container = container.createExtension({ + points: { + default: { + typeRef: coreExtensionPointTypes.component, + configSchema: z.object({}), + }, + }, + mountTypeRef: coreExtensionPointTypes.component, + factory: + ({ id, points }) => + () => + , +}); + +interface ExtensionPointTypeRef { + T: T; + $$type: 'extension-point-type'; +} + +function createExtensionPointTypeRef(): ExtensionPointTypeRef { + return { T: null as T, $$type: 'extension-point-type' }; +} + +interface ExtensionPointRef { + name: string; + typeRef: ExtensionPointTypeRef; + $$type: 'extension-point'; +} + +function createExtensionPointRef( + name: string, + typeRef: ExtensionPointTypeRef, +): ExtensionPointRef { + return { name, typeRef, $$type: 'extension-point' }; +} + +// const splitLayoutTopExtensionPointRef = createExtensionPointRef({ +// type: componentExtensionPointTypeRef, +// point: 'top' +// }) + +// const splitLayoutBottomExtensionPointRef = createExtensionPointRef({ +// type: componentExtensionPointTypeRef, +// point: 'bottom' +// }) + +const SplitLayout = container.createExtension({ + points: { + top: { + typeRef: coreExtensionPointTypes.component, + configSchema: z.object({}), + }, + bottom: { + typeRef: coreExtensionPointTypes.component, + configSchema: z.object({}), + }, + }, + mountTypeRef: coreExtensionPointTypes.component, + factory: + ({ id, points }) => + () => { + return ( +
+
+ +
+
+ +
+
+ ); + }, +}); + +// const SnazzySplitLayout = container.replaceExtension(SplitLayout, { +// factory: ({ id, points }) => { +// ... +// } +// }) + +const Box = container.createExtension({ + points: {}, + mountTypeRef: coreExtensionPointTypes.component, + factory: ({ config }: { config: { color: string } }) => { + return () => ( + /** + * If I need stuff, I have to get it from context. + */ +
+ {config.color} +
+ ); + }, +}); + +const StyledBox = container.createExtension({ + points: { + style: { + typeRef: coreExtensionPointTypes.styles, + configSchema: z.object({}), + }, + }, + mountTypeRef: coreExtensionPointTypes.component, + factory: ({ id, points }) => { + return () => { + const [style] = useExtensionInstanceChildren(id, points.style); + + return
Styled box
; + }; + }, +}); + +const BoxStyle = container.createExtension({ + points: {}, + mountTypeRef: coreExtensionPointTypes.styles, + factory({ config }: { config: { color: string } }) { + return { + borderColor: config.color, + borderStyle: 'solid', + borderWidth: '5px', + width: 120, + height: 120, + }; + }, +}); + +function createExtensionInstances( + instanceConfigs: ExtensionInstanceConfig[], +): ExtensionInstance[] { + return instanceConfigs.map(instanceConfig => ({ + id: instanceConfig.id, + mount: instanceConfig.mount, + output: instanceConfig.extension.factory({ + id: instanceConfig.id, + config: instanceConfig.config, + }), + })); +} + +const ExtensionInstanceRenderer = (props: { id: string }) => { const { id } = props; const { extensionInstances } = React.useContext(BackstageAppContext); @@ -83,74 +311,45 @@ const ExtensionInstanceDerp = (props: { id: string }) => { return ; }; -const ExtensionPointInstance = (props: { id: string }) => { - const { extensionInstances } = React.useContext(BackstageAppContext); - - return ( - <> - {extensionInstances - .filter(i => i.point === props.id) - .map(extensionInstance => ( - - ))} - - ); -}; - -const Container = container.createExtension({ - render: ({ id }) => , -}); - -const Box = container.createExtension({ - render: ({ config }: { config: { color: string } }) => { - return ( -
- {config.color} -
- ); - }, -}); - -function createExtensionInstances( - instanceConfigs: ExtensionInstanceConfig[], -): ExtensionInstance[] { - return instanceConfigs.map(instanceConfig => ({ - id: instanceConfig.id, - point: instanceConfig.point, - output: instanceConfig.extension.factory({ - id: instanceConfig.id, - config: instanceConfig.config, - }), - })); -} - export function Experiment1() { - // const element = useExtension("red"); - const extensionInstances = createExtensionInstances([ { id: 'root', config: {}, extension: Container }, - { id: 'nestedRoot', point: 'root', config: {}, extension: Container }, + { + id: 'layout', + mount: 'root/default', // Maybe can omit /default here? + config: {}, + extension: SplitLayout, + }, { id: 'red', - point: 'nestedRoot', + mount: 'layout/top', config: { color: 'red' }, extension: Box, }, { id: 'green', - point: 'nestedRoot', + mount: 'layout/top', config: { color: 'green' }, extension: Box, }, { id: 'blue', - point: 'nestedRoot', + mount: 'layout/bottom', config: { color: 'blue' }, extension: Box, }, + { + id: 'styled-box', + mount: 'layout/bottom', + config: {}, + extension: StyledBox, + }, + { + id: 'styled-box.style', + mount: 'styled-box/style', + config: { color: 'purple' }, + extension: BoxStyle, + }, ]); return ( @@ -161,8 +360,10 @@ export function Experiment1() { }} >

Experiment 1

-

This is an experiment to see how the app-experiments package works.

- + + This is an experiment to see how the app-experiments package works. + + ); } From db05b680db124978989f31719f0b36ae77230913 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 9 Jun 2023 14:16:40 +0200 Subject: [PATCH 05/22] app-experiment: try out a pattern for mount config 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 Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment1.tsx | 68 +++++++++++++++----- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx index 6453ad1179..0b5496f431 100644 --- a/packages/app-experiments/src/Experiment1.tsx +++ b/packages/app-experiments/src/Experiment1.tsx @@ -192,14 +192,19 @@ function createExtensionPointRef( // point: 'bottom' // }) -const SplitLayout = container.createExtension({ +const gridItemExtensionPointTypeRef = createExtensionPointTypeRef<{ + title: string; + Component: ComponentType; +}>(); + +const SplitGridLayout = container.createExtension({ points: { top: { - typeRef: coreExtensionPointTypes.component, + typeRef: gridItemExtensionPointTypeRef, configSchema: z.object({}), }, bottom: { - typeRef: coreExtensionPointTypes.component, + typeRef: gridItemExtensionPointTypeRef, configSchema: z.object({}), }, }, @@ -207,6 +212,8 @@ const SplitLayout = container.createExtension({ factory: ({ id, points }) => () => { + const topCards = useExtensionInstanceChildren(id, points.top); + const bottomCards = useExtensionInstanceChildren(id, points.bottom); return (
- + {topCards.map(card => ( + <> +

{card.title}

+ + + ))}
- + {bottomCards.map(card => ( + <> +

{card.title}

+ + + ))}
); }, }); -// const SnazzySplitLayout = container.replaceExtension(SplitLayout, { +// const SnazzySplitLayout = container.replaceExtension(SplitGridLayout, { // factory: ({ id, points }) => { // ... // } @@ -238,9 +255,9 @@ const SplitLayout = container.createExtension({ const Box = container.createExtension({ points: {}, - mountTypeRef: coreExtensionPointTypes.component, - factory: ({ config }: { config: { color: string } }) => { - return () => ( + mountTypeRef: gridItemExtensionPointTypeRef, + factory: ({ config }: { config: { color: string; title?: string } }) => { + const Component = () => ( /** * If I need stuff, I have to get it from context. */ @@ -248,6 +265,9 @@ const Box = container.createExtension({ {config.color}
); + // registerOutput(gridItemExtensionPointTypeRef, {title: config.title ?? ''}) + // registerOutput(coreExtensionPointTypes.component, Component) + return { Component, title: config.title ?? '' }; }, }); @@ -258,13 +278,14 @@ const StyledBox = container.createExtension({ configSchema: z.object({}), }, }, - mountTypeRef: coreExtensionPointTypes.component, + mountTypeRef: gridItemExtensionPointTypeRef, factory: ({ id, points }) => { - return () => { + const Component = () => { const [style] = useExtensionInstanceChildren(id, points.style); return
Styled box
; }; + return { Component, title: 'derp' }; }, }); @@ -318,24 +339,24 @@ export function Experiment1() { id: 'layout', mount: 'root/default', // Maybe can omit /default here? config: {}, - extension: SplitLayout, + extension: SplitGridLayout, }, { id: 'red', mount: 'layout/top', - config: { color: 'red' }, + config: { color: 'red', title: 'RED' }, extension: Box, }, { id: 'green', mount: 'layout/top', - config: { color: 'green' }, + config: { color: 'green', title: 'GREEN' }, extension: Box, }, { id: 'blue', mount: 'layout/bottom', - config: { color: 'blue' }, + config: { color: 'blue', title: 'BLUE' }, extension: Box, }, { @@ -350,6 +371,12 @@ export function Experiment1() { config: { color: 'purple' }, extension: BoxStyle, }, + // { + // id: 'catalogPage', + // mount: 'core/routes', + // config: { path: '/catalog' }, + // extension: Route, + // }, ]); return ( @@ -368,6 +395,17 @@ export function Experiment1() { ); } +/** + * Continued exploration: + * + * - routing + * - dynamic extension points + * - mount point config + * - declarative mixed with code, e.g. + * - lazy loading + * - defaults + */ + /* // graphiql-plugin From db353d051c18ad2bb32e71d33942eb8334c0d360 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 12 Jun 2023 15:47:44 +0200 Subject: [PATCH 06/22] app-experiment: starting point for experiment 2 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 Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment2.tsx | 135 +++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/app-experiments/src/Experiment2.tsx diff --git a/packages/app-experiments/src/Experiment2.tsx b/packages/app-experiments/src/Experiment2.tsx new file mode 100644 index 0000000000..f188114b50 --- /dev/null +++ b/packages/app-experiments/src/Experiment2.tsx @@ -0,0 +1,135 @@ +/* + * 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, { CSSProperties, ComponentType, createContext } from 'react'; +import { z } from 'zod'; +import mapValues from 'lodash/mapValues'; +import { Typography } from '@material-ui/core'; + +/** + * App structure + * - Root + * - GroceryLayout (fruit + vegetable columns} + * - Apple [grocery, fruit, title, component] + * - Orange [grocery, fruit, title, component] + * - Carrot [grocery, vegetable, component] + * - Tomato [grocery, vegetable, title, component] + * - Banana [grocery, title, component] + * - Metallica [band, title] + */ + +interface ExtensionDataRef { + id: string; + T: T; + $$type: 'extension-data'; +} + +function createExtensionDataRef(id: string) { + return { id } as ExtensionDataRef; +} + +const coreExtensionData = { + reactComponent: createExtensionDataRef<{ Component: ComponentType }>( + 'core.reactComponent', + ), + routable: createExtensionDataRef<{ path: string }>('core.routable'), + title: createExtensionDataRef<{ title: string }>('core.title'), +}; + +createPlugin({ + id: 'catalog', + extension: [], +}); + +function createExtension(options: { id: string }) { + return options; +} + +createExtension1({ + // id: 'catalog', + // features: [ + // { + // typeRef: coreExtensionData.routable, + // data: { + // path: 'catalog' + // } + // } + // ], + // init({ bind }) { + // bind(coreExtensionData.routable, { + // path: 'catalog' + // }) + // }, + // factory(bind, config, mountConfig) { + // bind(coreExtensionData.routable, { + // path: 'catalog' + // }) + // }, + extensionData: { + // Record> ? + routable: { ref: coreExtensionData.routable, optional: true }, + title: coreExtensionData.title, + component: coreExtensionData.reactComponent, + }, + configSchema: z.object({ + path: z.string(), + title: z.string().default('Ma title'), + }), + factory(bind, config) { + bind.title({ title: config.title }); + const Component = () =>
hello
; + bind.component({ Component }); + bind.routable({ path: config.path }); + }, +}); + +function createEntityContentExtension() {} + +const AboutCard = createEntityContentExtension({ + component: () =>
hello
, + extensionData: { + // Record> ? + channels: coreExtensionData.notificationChannels, + }, + configSchema: z.object({ + channelName: z.string(), + }), + factory(bind, config) {}, +}); + +function createEntityCardExtension(options: { configSchema }) { + return createExtension({ + configSchema: options.configSchema( + z.object({ + path: z.string(), + title: z.string(), + }), + ), + factory() {}, + }); +} + +const createEntityCardExtension = createExtensionFactory({ + baseConfigSchema: z.object({ + path: z.string(), + title: z.string(), + }), +}); + +const AboutCard = createEntityCardExtension({ + // features: [catalogCard], + configSchema: parent => parent.union(), +}); From 8dbb9dc8740ce448d719bfacfe5d6cd3b4fd9cc9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 12 Jun 2023 16:47:45 +0200 Subject: [PATCH 07/22] app-experiment: rendering fruits but not metallica 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 Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment2.tsx | 215 ++++++++++++------- packages/app-experiments/src/index.tsx | 4 +- 2 files changed, 135 insertions(+), 84 deletions(-) diff --git a/packages/app-experiments/src/Experiment2.tsx b/packages/app-experiments/src/Experiment2.tsx index f188114b50..ef803803c8 100644 --- a/packages/app-experiments/src/Experiment2.tsx +++ b/packages/app-experiments/src/Experiment2.tsx @@ -14,21 +14,16 @@ * limitations under the License. */ -import React, { CSSProperties, ComponentType, createContext } from 'react'; -import { z } from 'zod'; +import React, { ComponentType } from 'react'; import mapValues from 'lodash/mapValues'; -import { Typography } from '@material-ui/core'; /** * App structure * - Root - * - GroceryLayout (fruit + vegetable columns} - * - Apple [grocery, fruit, title, component] - * - Orange [grocery, fruit, title, component] - * - Carrot [grocery, vegetable, component] - * - Tomato [grocery, vegetable, title, component] - * - Banana [grocery, title, component] - * - Metallica [band, title] + * - GroceryLayout (fruit} + * - Apple [grocery, fruit, component] + * - Orange [grocery, fruit, component] + * - Metallica [band] */ interface ExtensionDataRef { @@ -42,94 +37,150 @@ function createExtensionDataRef(id: string) { } const coreExtensionData = { - reactComponent: createExtensionDataRef<{ Component: ComponentType }>( - 'core.reactComponent', - ), - routable: createExtensionDataRef<{ path: string }>('core.routable'), - title: createExtensionDataRef<{ title: string }>('core.title'), + reactComponent: createExtensionDataRef('core.reactComponent'), + + isGrocery: createExtensionDataRef('isGrocery'), + isFruit: createExtensionDataRef('isFruit'), + + isBand: createExtensionDataRef('isBand'), + title: createExtensionDataRef('core.title'), }; -createPlugin({ - id: 'catalog', - extension: [], -}); +type AnyExtensionDataMap = Record>; -function createExtension(options: { id: string }) { +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, +> { + extensionData: TData; + points?: TPoint; + factory(options: { + bind: ExtensionDataBind; + config?: unknown; + points: { + [pointName in keyof TPoint]: ExtensionDataValue< + TPoint[pointName]['extensionData'] + >[]; + }; + }): void; +} + +function createExtension< + TData extends AnyExtensionDataMap, + TPoint extends Record, +>(options: CreateExtensionOptions) { return options; } -createExtension1({ - // id: 'catalog', - // features: [ - // { - // typeRef: coreExtensionData.routable, - // data: { - // path: 'catalog' - // } - // } - // ], - // init({ bind }) { - // bind(coreExtensionData.routable, { - // path: 'catalog' - // }) - // }, - // factory(bind, config, mountConfig) { - // bind(coreExtensionData.routable, { - // path: 'catalog' - // }) - // }, +const GroceryLayout = createExtension({ + points: { + groceries: { + // requiredData: [ + // coreExtensionData.reactComponent, + // coreExtensionData.isGrocery, + // ], + extensionData: { + Component: coreExtensionData.reactComponent, + isGrocery: coreExtensionData.isGrocery, + }, + }, + }, extensionData: { - // Record> ? - routable: { ref: coreExtensionData.routable, optional: true }, - title: coreExtensionData.title, component: coreExtensionData.reactComponent, }, - configSchema: z.object({ - path: z.string(), - title: z.string().default('Ma title'), - }), - factory(bind, config) { - bind.title({ title: config.title }); - const Component = () =>
hello
; - bind.component({ Component }); - bind.routable({ path: config.path }); + factory({ bind, points }) { + const elements = points.groceries.map(point => ); + /** + * Probably easier to evolve over time + * const elements = points.groceries.map((point) => getExtensionData(point, coreExtensionData.reactComponent)) + **/ + const Component = () =>
yo yo yo, here's my elements: {elements}
; + bind.component(Component); }, }); -function createEntityContentExtension() {} - -const AboutCard = createEntityContentExtension({ - component: () =>
hello
, +const Apple = createExtension({ extensionData: { - // Record> ? - channels: coreExtensionData.notificationChannels, + component: coreExtensionData.reactComponent, + isGrocery: coreExtensionData.isGrocery, + isFruit: coreExtensionData.isFruit, + }, + factory({ bind }) { + const Component = () =>
apple
; + bind.component(Component); + bind.isGrocery(true); + bind.isFruit(true); }, - configSchema: z.object({ - channelName: z.string(), - }), - factory(bind, config) {}, }); -function createEntityCardExtension(options: { configSchema }) { - return createExtension({ - configSchema: options.configSchema( - z.object({ - path: z.string(), - title: z.string(), - }), - ), - factory() {}, +const Metallica = createExtension({ + extensionData: { + component: coreExtensionData.reactComponent, + }, + factory({ bind }) { + const Component = () =>
HEAVY METAL YEAH
; + bind.component(Component); + }, +}); + +const Orange = createExtension({ + extensionData: { + component: coreExtensionData.reactComponent, + isGrocery: coreExtensionData.isGrocery, + isFruit: coreExtensionData.isFruit, + }, + factory({ bind }) { + const Component = () =>
orange
; + bind.component(Component); + bind.isGrocery(true); + bind.isFruit(true); + }, +}); + +function createExtensionInstance( + options: CreateExtensionOptions< + AnyExtensionDataMap, + Record + >, + children: { data: Map, unknown> }[], +) { + const extensionData = new Map, unknown>(); + options.factory({ + bind: mapValues(options.extensionData, ref => { + return (value: unknown) => extensionData.set(ref, value); + }), + points: mapValues(options.points, ({ extensionData: pointData }) => { + const requiredRef = Object.values(pointData); + const matchingChildren = children.filter(child => + requiredRef.every(ref => child.data.has(ref)), + ); + return matchingChildren.map(child => + mapValues(pointData, ref => child.data.get(ref)), + ); + }), }); + return { data: extensionData }; } -const createEntityCardExtension = createExtensionFactory({ - baseConfigSchema: z.object({ - path: z.string(), - title: z.string(), - }), -}); - -const AboutCard = createEntityCardExtension({ - // features: [catalogCard], - configSchema: parent => parent.union(), -}); +export function Experiment2() { + const apple = createExtensionInstance(Apple, []); + const orange = createExtensionInstance(Orange, []); + const metallica = createExtensionInstance(Metallica, []); + const layout = createExtensionInstance(GroceryLayout, [ + apple, + orange, + metallica, + ]); + const Component = layout.data.get( + coreExtensionData.reactComponent, + ) as ComponentType; + return ; +} diff --git a/packages/app-experiments/src/index.tsx b/packages/app-experiments/src/index.tsx index 6f0434c15e..3abd870c17 100644 --- a/packages/app-experiments/src/index.tsx +++ b/packages/app-experiments/src/index.tsx @@ -17,6 +17,6 @@ import '@backstage/cli/asset-types'; import React from 'react'; import ReactDOM from 'react-dom'; -import { Experiment1 } from './Experiment1'; +import { Experiment2 } from './Experiment2'; -ReactDOM.render(, document.getElementById('root')); +ReactDOM.render(, document.getElementById('root')); From 0a254a2b92710777a546a03c27686cb23cbaf7f7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 13 Jun 2023 17:22:38 +0200 Subject: [PATCH 08/22] app-experiments: experiment 3 - naming things 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: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-experiments/src/Experiment3.tsx | 193 +++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 packages/app-experiments/src/Experiment3.tsx diff --git a/packages/app-experiments/src/Experiment3.tsx b/packages/app-experiments/src/Experiment3.tsx new file mode 100644 index 0000000000..e0cdd96af4 --- /dev/null +++ b/packages/app-experiments/src/Experiment3.tsx @@ -0,0 +1,193 @@ +/* + * 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. + */ + +type Extension = { + factory(instanceOptions: { id: string; config: TInstanceConfig }): TSelf; +}; + +type ExtensionInstance = { + id: string; + // what should point be for root + mount?: string; + output: TSelf; +}; + +const ExtensionInstanceRenderer = (props: { id: string }) => { + const { id } = props; + const { extensionInstances } = React.useContext(BackstageAppContext); + const value = extensionInstances.find(i => i.id === id); + if (!value) { + throw new Error(`No extension instance found with id ${id}`); + } + const ComponentInstance = value.output as React.ComponentType; + return ; +}; + +export function Experiment1() { + const extensionInstances = createExtensionInstances([ + { + id: 'layout', + parent: 'root', // Maybe can omit /default here? + config: {}, + use: SplitGridLayout, + }, + { + id: 'red', + mount: 'layout/top', + config: { color: 'red', title: 'RED' }, + use: Box, + }, + { + // green is a Box on layout/top with color=green title=GREEN + // alias, name, id, ... + // green is a Box extension attached to the `layout` extension instance on the `top` + // extension point with config { ... }. + id: 'green', + // at: { id: 'layout', point: 'top' }, + at: 'layout/top', + extension: '@backstage/plugin-catalog-react#Box', + config: { color: 'green', title: 'GREEN' }, + }, + { + id: 'blue', + at: 'layout/bottom', + extension: '@backstage/plugin-catalog-react#Box', + config: { color: 'blue', title: 'BLUE' }, + }, + + { + id: 'styled-box', + parent: 'layout/bottom', + config: { title: 'STYLED' }, + extension: StyledBox, + }, + { + id: 'styled-box.style', + parent: 'styled-box/style', + config: { color: 'purple' }, + extension: BoxStyle, + }, + + { + id: 'scaffolder.page', + bind: 'core.router/routes', + attach: 'core.router/routes', + target: 'core.router/routes', + connect: 'core.router/routes', + on: 'core.router/routes', + at: 'core.router/routes', + edge: 'core.router/routes', + config: { path: '/create' }, + extension: ScaffolderPage, + }, + { + id: 'scaffolder.fieldExtensions.derp', + attach: 'scaffolder.page/fieldExtension', + config: { color: 'purple' }, + extension: DerpScaffolderFieldExtension, + }, + ]); +} + +interface ExtensionDataRef { + id: string; + T: T; + $$type: 'extension-data'; +} + +function createExtensionDataRef(id: string) { + return { id } as ExtensionDataRef; +} + +const coreExtensionData = { + reactComponent: createExtensionDataRef('core.reactComponent'), + isGrocery: createExtensionDataRef('isGrocery'), + title: createExtensionDataRef('core.title'), +}; + +const groceryExtensionDataCollection = () => ({ + Component: coreExtensionData.reactComponent, + isGrocery: coreExtensionData.isGrocery, +}); + +const groceriesExtensionPoint = createExtensionPoint({ + extensionData: groceryExtensionDataCollection(), +}); + +const GroceryLayout = createExtension({ + points: { + groceries: groceriesExtensionPoint, + // groceries: { + // extensionData: groceryExtensionDataCollection(), + // }, + }, + extensionData: { + component: coreExtensionData.reactComponent, + }, + factory({ bind, points }) { + const elements = points.groceries.map(point => ); + const Component = () =>
yo yo yo, here's my elements: {elements}
; + bind.component(Component); + }, +}); + +// // plugin-*-react +// const GroceryLayoutExtensionDefinition = defineExtension({ +// input: { +// groceries: { +// component: coreExtensionData.reactComponent, +// isGrocery: coreExtensionData.isGrocery, +// } +// }, +// output: { +// component: coreExtensionData.reactComponent, +// }, +// }); + +// // plugin-* +// const GroceryLayoutExtension = createExtension(GroceryLayoutExtension, ({ bind, points }) => { +// ... +// }); + +const GroceryLayout = createExtension({ + inputs: { + groceries: { + Component: coreExtensionData.reactComponent, + isGrocery: coreExtensionData.isGrocery, + }, + }, + output: { + component: coreExtensionData.reactComponent, + }, + factory({ output, input }) { + const elements = input.groceries.map(point => ); + const Component = () =>
yo yo yo, here's my elements: {elements}
; + output.component.set({ component: Component }); + }, +}); + +const createGroceryExtension = createExtensionFactory(({ component }) => ({ + extensionData: groceryExtensionDataCollection(), + factory({ bind }) { + bind.component(component); + bind.isGrocery(true); + bind.isFruit(true); + }, +})); + +const Apple = createGroceryExtension({ + component: () =>
apple
, +}); From 0f514f99a2bf5d160df4b2078e0e21b469c1c3bb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Jun 2023 10:43:57 +0200 Subject: [PATCH 09/22] initial app-next 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: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-next/.eslintrc.js | 1 + packages/app-next/README.md | 3 + packages/app-next/app-config.yaml | 104 +++++++++ packages/app-next/package.json | 141 ++++++++++++ .../public/android-chrome-192x192.png | Bin 0 -> 13599 bytes .../public/android-chrome-512x512.png | Bin 0 -> 40325 bytes packages/app-next/public/apple-touch-icon.png | Bin 0 -> 12619 bytes packages/app-next/public/favicon-16x16.png | Bin 0 -> 883 bytes packages/app-next/public/favicon-32x32.png | Bin 0 -> 1686 bytes packages/app-next/public/favicon.ico | Bin 0 -> 15086 bytes packages/app-next/public/favicon.svg | 1 + packages/app-next/public/index.html | 98 +++++++++ packages/app-next/public/manifest.json | 15 ++ packages/app-next/public/robots.txt | 2 + .../app-next/public/safari-pinned-tab.svg | 1 + packages/app-next/src/App.test.tsx | 48 ++++ packages/app-next/src/App.tsx | 205 ++++++++++++++++++ packages/app-next/src/index.tsx | 22 ++ packages/app-next/src/setupTests.ts | 21 ++ yarn.lock | 110 ++++++++++ 20 files changed, 772 insertions(+) create mode 100644 packages/app-next/.eslintrc.js create mode 100644 packages/app-next/README.md create mode 100644 packages/app-next/app-config.yaml create mode 100644 packages/app-next/package.json create mode 100644 packages/app-next/public/android-chrome-192x192.png create mode 100644 packages/app-next/public/android-chrome-512x512.png create mode 100644 packages/app-next/public/apple-touch-icon.png create mode 100644 packages/app-next/public/favicon-16x16.png create mode 100644 packages/app-next/public/favicon-32x32.png create mode 100644 packages/app-next/public/favicon.ico create mode 100644 packages/app-next/public/favicon.svg create mode 100644 packages/app-next/public/index.html create mode 100644 packages/app-next/public/manifest.json create mode 100644 packages/app-next/public/robots.txt create mode 100644 packages/app-next/public/safari-pinned-tab.svg create mode 100644 packages/app-next/src/App.test.tsx create mode 100644 packages/app-next/src/App.tsx create mode 100644 packages/app-next/src/index.tsx create mode 100644 packages/app-next/src/setupTests.ts diff --git a/packages/app-next/.eslintrc.js b/packages/app-next/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/app-next/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/app-next/README.md b/packages/app-next/README.md new file mode 100644 index 0000000000..21645609a9 --- /dev/null +++ b/packages/app-next/README.md @@ -0,0 +1,3 @@ +# example-app-next + +This package is an example of a Backstage application. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml new file mode 100644 index 0000000000..6042c5ce2a --- /dev/null +++ b/packages/app-next/app-config.yaml @@ -0,0 +1,104 @@ + +app: + packages: 'all' # ✨ + extensions: [] + # - core.signInPage: + # props: + # provider: + # id: google + # title: Google + # message: Sign In using Google + # apiRef: googleAuthApiRef # ??? + + # - core.nav: + # config: + # logo: assets/logo.png + # layout: + # - label: Search + # icon: search + # to: /search + # items: + # - point: search + # - type: divider + # - label: Menu + # icon: menu + # items: + # - label: Home + # icon: home + # to: /catalog + # - label: Create... + # icon: create + # to: /create + # - type: divider + # - type: scroll-wrapper + # items: + # - label: Tech Radar + # icon: map + # to: /tech-radar + # - label: GraphiQL + # icon: graphiql + # to: /graphiql + # - type: divider + # - point: shortcuts + # - type: space + # - type: divider + # - label: Settings + # icon: avatar + # to: /settings + # items: + # - point: settings + # - core.nav/search: '@backstage/plugin-search#SidebarSearchModal' + # - core.nav/shortcuts: + # use: '@backstage/plugin-shortcuts#Shortcuts' + # config: + # allowExternalLinks: true + # - core.nav/settings: '@backstage/plugin-user-settings#SidebarSettings' + + # - core.pages.index: + # at: + # point: core.routes + # config: + # path: / + # use: 'react-router-dom#Navigate' + # config: + # to: /catalog + + # - scaffolder.page: + # config: + # groups: + # - title: Recommended + # filter: + # entity.metadata.tags: recommended + # - scaffolder.page/fields: '@backstage/plugin-scaffolder#LowerCaseValuePickerFieldExtension' + # - scaffolder.page/layout: '@backstage/plugin-scaffolder#TwoColumnLayout' + + # - search.page/content: 'app#customSearchPage' # custom search page from somewhere + + # - user-settings.page.routes.advanced: + # at: + # point: user-settings.page/routes + # config: + # title: Advanced + # path: /advanced + # use: '@backstage/plugin-user-settings#AdvancedSettings' + + # - entity.card.orphanWarning + # - entity.card.processingErrors + # - entity.card.about + # - entity.card.catalogGraph + # - entity.card.pagerDuty + # - entity.card.links + # - entity.card.labels + # - entity.card.githubInsightsLanguages + # - entity.card.githubInsightsReleases + # - entity.card.githubInsightsReadme: + # config: + # maxHeight: 350 # Throwing this config in to have an example, but in practice rely on default + # - entity.card.subcomponentsCard + # - entity.card.userProfile + # - entity.card.ownership + # - entity.card.likeDislikeRatings + # - entity.content.dependsOnComponents + # - entity.content.codeInsights + # - entity.content.todo + # - entity.content.feedbackResponse diff --git a/packages/app-next/package.json b/packages/app-next/package.json new file mode 100644 index 0000000000..35a8e02cbc --- /dev/null +++ b/packages/app-next/package.json @@ -0,0 +1,141 @@ +{ + "name": "example-app-next", + "version": "0.0.0", + "private": true, + "backstage": { + "role": "frontend" + }, + "bundled": true, + "dependencies": { + "@backstage/app-defaults": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/integration-react": "workspace:^", + "@backstage/plugin-adr": "workspace:^", + "@backstage/plugin-airbrake": "workspace:^", + "@backstage/plugin-apache-airflow": "workspace:^", + "@backstage/plugin-api-docs": "workspace:^", + "@backstage/plugin-azure-devops": "workspace:^", + "@backstage/plugin-azure-sites": "workspace:^", + "@backstage/plugin-badges": "workspace:^", + "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-catalog-graph": "workspace:^", + "@backstage/plugin-catalog-import": "workspace:^", + "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-catalog-unprocessed-entities": "workspace:^", + "@backstage/plugin-circleci": "workspace:^", + "@backstage/plugin-cloudbuild": "workspace:^", + "@backstage/plugin-code-coverage": "workspace:^", + "@backstage/plugin-cost-insights": "workspace:^", + "@backstage/plugin-devtools": "workspace:^", + "@backstage/plugin-dynatrace": "workspace:^", + "@backstage/plugin-entity-feedback": "workspace:^", + "@backstage/plugin-explore": "workspace:^", + "@backstage/plugin-gcalendar": "workspace:^", + "@backstage/plugin-gcp-projects": "workspace:^", + "@backstage/plugin-github-actions": "workspace:^", + "@backstage/plugin-gocd": "workspace:^", + "@backstage/plugin-graphiql": "workspace:^", + "@backstage/plugin-home": "workspace:^", + "@backstage/plugin-jenkins": "workspace:^", + "@backstage/plugin-kafka": "workspace:^", + "@backstage/plugin-kubernetes": "workspace:^", + "@backstage/plugin-lighthouse": "workspace:^", + "@backstage/plugin-linguist": "workspace:^", + "@backstage/plugin-linguist-common": "workspace:^", + "@backstage/plugin-microsoft-calendar": "workspace:^", + "@backstage/plugin-newrelic": "workspace:^", + "@backstage/plugin-newrelic-dashboard": "workspace:^", + "@backstage/plugin-octopus-deploy": "workspace:^", + "@backstage/plugin-org": "workspace:^", + "@backstage/plugin-pagerduty": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", + "@backstage/plugin-playlist": "workspace:^", + "@backstage/plugin-puppetdb": "workspace:^", + "@backstage/plugin-rollbar": "workspace:^", + "@backstage/plugin-scaffolder": "workspace:^", + "@backstage/plugin-scaffolder-react": "workspace:^", + "@backstage/plugin-search": "workspace:^", + "@backstage/plugin-search-common": "workspace:^", + "@backstage/plugin-search-react": "workspace:^", + "@backstage/plugin-sentry": "workspace:^", + "@backstage/plugin-shortcuts": "workspace:^", + "@backstage/plugin-stack-overflow": "workspace:^", + "@backstage/plugin-stackstorm": "workspace:^", + "@backstage/plugin-tech-insights": "workspace:^", + "@backstage/plugin-tech-radar": "workspace:^", + "@backstage/plugin-techdocs": "workspace:^", + "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^", + "@backstage/plugin-techdocs-react": "workspace:^", + "@backstage/plugin-todo": "workspace:^", + "@backstage/plugin-user-settings": "workspace:^", + "@backstage/theme": "workspace:^", + "@internal/plugin-catalog-customized": "workspace:^", + "@material-ui/core": "^4.12.2", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.61", + "@octokit/rest": "^19.0.3", + "@oriflame/backstage-plugin-score-card": "^0.7.0", + "@roadiehq/backstage-plugin-buildkite": "^2.0.8", + "@roadiehq/backstage-plugin-github-insights": "^2.0.5", + "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", + "@roadiehq/backstage-plugin-travis-ci": "^2.0.5", + "history": "^5.0.0", + "lodash": "^4.17.21", + "prop-types": "^15.7.2", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-router": "^6.3.0", + "react-router-dom": "^6.3.0", + "react-use": "^17.2.4", + "zen-observable": "^0.10.0" + }, + "devDependencies": { + "@backstage/test-utils": "workspace:^", + "@testing-library/cypress": "^9.0.0", + "@testing-library/dom": "^8.0.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/jquery": "^3.3.34", + "@types/node": "^16.11.26", + "@types/react": "*", + "@types/react-dom": "*", + "@types/zen-observable": "^0.8.0", + "cross-env": "^7.0.0", + "cypress": "^10.0.0", + "eslint-plugin-cypress": "^2.10.3", + "start-server-and-test": "^1.10.11" + }, + "scripts": { + "start": "backstage-cli package start --config app-config.yaml --config packages/app-next/app-config.yaml", + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "test": "backstage-cli package test", + "lint": "backstage-cli package lint", + "test:e2e": "start-server-and-test start http://localhost:3000 cy:dev", + "test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run", + "cy:dev": "cypress open", + "cy:run": "cypress run" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "files": [ + "dist" + ], + "license": "Apache-2.0" +} diff --git a/packages/app-next/public/android-chrome-192x192.png b/packages/app-next/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..eec0ae25b971cae8eb0033c9af7e0f676d1df663 GIT binary patch literal 13599 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 EANz~b2mk;8 literal 0 HcmV?d00001 diff --git a/packages/app-next/public/android-chrome-512x512.png b/packages/app-next/public/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..ac0578133bd74d60504d165216e9d4f6ba2fbfff GIT binary patch literal 40325 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

5PqA0)3L-3@%qNim8AQI->nx5 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 literal 0 HcmV?d00001 diff --git a/packages/app-next/public/apple-touch-icon.png b/packages/app-next/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6a45afb38f08b3dcd46d250bf544c328688363b8 GIT binary patch literal 12619 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 VSn|oqbSlsa22WQ%mvv4FO#qym7y1AI literal 0 HcmV?d00001 diff --git a/packages/app-next/public/favicon-32x32.png b/packages/app-next/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..c0915ece75949f3d917134f55193949927edc633 GIT binary patch literal 1686 zcmd5*YdF&j82@WYD9ojqT$Wo{SSXitp^eRqxy{@++t}P`atlKxQX0)YMm6_KR%p4F z$}J+6LMeH2OXWDHjylI#AJ2#L{k+fbeSgn;`91HK_szmP+3%H7kpcicc}5$z1N!Px-7om}akWbt233fdJ10ExN)z&Z~ATcQ$c2>@ad0I)&=0IMPZ zfJBuET&w|L$8)@+J4!Sl|Nk(stgJlvmnlJ&;dZvl8tO{Q%IJ*5_~q$1+xa={mdc0(T`t!w?RZl>^IxA*%bGu3bF$<*nzTrF> zoQ&9oiO|s=ORAp|1maSVle9kTR(cJ300u{&4iq#dewwjMjZ;umbia|8`fV}#)u6uZ z2{{=Vn6Y8_#3SbXb1ic-&s#nMteerJ5NT@9WJFf_0Z{rXvToRrSOV|5nT6}g)=)7clCVKtHJ1*VPs-V;@XTM)*hF8A>dvO zHk*Y?X8JaA;})mFp7;7TRRuk~8$R*Snh{9txf3X8a4%y!TuP;MHGAGHB;T*cTs()( zPBU^uyOrcpI-AkwlQc~ccBy9xRRz@U7IH^D@piFSO__BF#e(AN-^z2yOmV!NrfGJV z)K=@)T;PiA16X29aqmP@px{GT+&pJ*OCU>i#GE8dw3RuCdu^ zY|d<%y|L(_@zJ9#_m z=Lc$*iilj{bjSDeNH|lRP>yLE)Yq?l!XVsP?uHhR(xnf>5p}vuAuF-j7*tA0oTAH zhFP&ZPMN7z8!K6i|K8u7|AYJvRll0p!ov{KSr|$njU-`o&7fVZN5@WQ|g`Mlirnap=oY~b#iCdHeScp|7sct zd}FL!1NSuuCnVle?ygaP975V!)pzN#*|A{{1* z$fS~)G%HFBO+IIv4>f5HNGY_6SF`0>Gf0kWIFv^nU=X CgrE5U literal 0 HcmV?d00001 diff --git a/packages/app-next/public/favicon.ico b/packages/app-next/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5e45e5dfbde6f39603d5be60d933c1af14dffb1e GIT binary patch literal 15086 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 D+pS%} literal 0 HcmV?d00001 diff --git a/packages/app-next/public/favicon.svg b/packages/app-next/public/favicon.svg new file mode 100644 index 0000000000..7f2d375cd8 --- /dev/null +++ b/packages/app-next/public/favicon.svg @@ -0,0 +1 @@ +Backstage favicon \ No newline at end of file diff --git a/packages/app-next/public/index.html b/packages/app-next/public/index.html new file mode 100644 index 0000000000..0710a0d69d --- /dev/null +++ b/packages/app-next/public/index.html @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + <%= config.getString('app.title') %> + + <% if (config.has('app.datadogRum')) { %> + + <% } %> + + + + +

+ + + diff --git a/packages/app-next/public/manifest.json b/packages/app-next/public/manifest.json new file mode 100644 index 0000000000..4a7c1b4ec4 --- /dev/null +++ b/packages/app-next/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "Backstage", + "name": "Backstage", + "icons": [ + { + "src": "favicon.ico", + "sizes": "48x48", + "type": "image/png" + } + ], + "start_url": "./index.html", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/app-next/public/robots.txt b/packages/app-next/public/robots.txt new file mode 100644 index 0000000000..01b0f9a107 --- /dev/null +++ b/packages/app-next/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/app-next/public/safari-pinned-tab.svg b/packages/app-next/public/safari-pinned-tab.svg new file mode 100644 index 0000000000..0f500b3002 --- /dev/null +++ b/packages/app-next/public/safari-pinned-tab.svg @@ -0,0 +1 @@ +Created by potrace 1.11, written by Peter Selinger 2001-2013 \ No newline at end of file diff --git a/packages/app-next/src/App.test.tsx b/packages/app-next/src/App.test.tsx new file mode 100644 index 0000000000..8c7cefeb2c --- /dev/null +++ b/packages/app-next/src/App.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 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 { renderWithEffects } from '@backstage/test-utils'; +import App from './App'; + +describe('App', () => { + it('should render', async () => { + process.env = { + NODE_ENV: 'test', + APP_CONFIG: [ + { + data: { + app: { + title: 'Test', + support: { url: 'http://localhost:7007/support' }, + }, + backend: { baseUrl: 'http://localhost:7007' }, + lighthouse: { + baseUrl: 'http://localhost:3003', + }, + techdocs: { + storageUrl: 'http://localhost:7007/api/techdocs/static/docs', + }, + }, + context: 'test', + }, + ] as any, + }; + + const rendered = await renderWithEffects(); + expect(rendered.baseElement).toBeInTheDocument(); + }); +}); diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx new file mode 100644 index 0000000000..a93a934301 --- /dev/null +++ b/packages/app-next/src/App.tsx @@ -0,0 +1,205 @@ +/* + * 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 { AppRouter, FlatRoutes } from '@backstage/core-app-api'; + +import { catalogPlugin } from '@internal/plugin-catalog-customized'; + +import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; +import { GraphiQLPage } from '@backstage/plugin-graphiql'; +import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { TechRadarPage } from '@backstage/plugin-tech-radar'; +import React, { ComponentType } from 'react'; +import { Route } from 'react-router-dom'; +import mapValues from 'lodash/mapValues'; + +/* core */ + +const discoverPackages = async () => { + // stub for now, deferring package discovery til later + return ['@backstage/plugin-graphiql']; +}; + +function createApp() {} + +function createPlugin() {} + +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 ?? {} }; +} + +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 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' }; +} + +/* graphiql package */ + +const GraphiqlRoute = createExtension({ + inputs: { + extensionData: { + component: coreExtensionData.reactComponent, + }, + }, + factory({ bind, points }) { + // ... + }, +}); + +/* app.tsx */ + +const app = createApp({ + // bindRoutes({ bind }) { + // bind(catalogPlugin.externalRoutes, { + // createComponent: scaffolderPlugin.routes.root, + // }); + // bind(scaffolderPlugin.externalRoutes, { + // registerComponent: catalogImportPlugin.routes.importPage, + // }); + // }, +}); + +const routes = ( + + {/* } /> + } /> + } + > + + + + + + + + + + + + + + + + + + + + } + /> */} + {/* } + /> */} + } /> + +); + +export default app.createRoot( + <> + {/* + */} + {routes} + , +); diff --git a/packages/app-next/src/index.tsx b/packages/app-next/src/index.tsx new file mode 100644 index 0000000000..b15bc4c102 --- /dev/null +++ b/packages/app-next/src/index.tsx @@ -0,0 +1,22 @@ +/* + * Copyright 2020 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 '@backstage/cli/asset-types'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render(, document.getElementById('root')); diff --git a/packages/app-next/src/setupTests.ts b/packages/app-next/src/setupTests.ts new file mode 100644 index 0000000000..23fcbe9676 --- /dev/null +++ b/packages/app-next/src/setupTests.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 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. + */ + +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index e5c4f02164..af8ced2b00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19355,6 +19355,7 @@ __metadata: cypress: ^10.0.0 eslint-plugin-cypress: ^2.10.3 history: ^5.0.0 + lodash: ^4.17.21 prop-types: ^15.7.2 react: ^17.0.2 react-dom: ^17.0.2 @@ -19363,6 +19364,7 @@ __metadata: react-use: ^17.2.4 start-server-and-test: ^1.10.11 zen-observable: ^0.10.0 + zod: ^3.21.4 languageName: unknown linkType: soft @@ -25171,6 +25173,114 @@ __metadata: languageName: node linkType: hard +"example-app-next@workspace:packages/app-next": + version: 0.0.0-use.local + resolution: "example-app-next@workspace:packages/app-next" + dependencies: + "@backstage/app-defaults": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/integration-react": "workspace:^" + "@backstage/plugin-adr": "workspace:^" + "@backstage/plugin-airbrake": "workspace:^" + "@backstage/plugin-apache-airflow": "workspace:^" + "@backstage/plugin-api-docs": "workspace:^" + "@backstage/plugin-azure-devops": "workspace:^" + "@backstage/plugin-azure-sites": "workspace:^" + "@backstage/plugin-badges": "workspace:^" + "@backstage/plugin-catalog-common": "workspace:^" + "@backstage/plugin-catalog-graph": "workspace:^" + "@backstage/plugin-catalog-import": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-catalog-unprocessed-entities": "workspace:^" + "@backstage/plugin-circleci": "workspace:^" + "@backstage/plugin-cloudbuild": "workspace:^" + "@backstage/plugin-code-coverage": "workspace:^" + "@backstage/plugin-cost-insights": "workspace:^" + "@backstage/plugin-devtools": "workspace:^" + "@backstage/plugin-dynatrace": "workspace:^" + "@backstage/plugin-entity-feedback": "workspace:^" + "@backstage/plugin-explore": "workspace:^" + "@backstage/plugin-gcalendar": "workspace:^" + "@backstage/plugin-gcp-projects": "workspace:^" + "@backstage/plugin-github-actions": "workspace:^" + "@backstage/plugin-gocd": "workspace:^" + "@backstage/plugin-graphiql": "workspace:^" + "@backstage/plugin-home": "workspace:^" + "@backstage/plugin-jenkins": "workspace:^" + "@backstage/plugin-kafka": "workspace:^" + "@backstage/plugin-kubernetes": "workspace:^" + "@backstage/plugin-lighthouse": "workspace:^" + "@backstage/plugin-linguist": "workspace:^" + "@backstage/plugin-linguist-common": "workspace:^" + "@backstage/plugin-microsoft-calendar": "workspace:^" + "@backstage/plugin-newrelic": "workspace:^" + "@backstage/plugin-newrelic-dashboard": "workspace:^" + "@backstage/plugin-octopus-deploy": "workspace:^" + "@backstage/plugin-org": "workspace:^" + "@backstage/plugin-pagerduty": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" + "@backstage/plugin-playlist": "workspace:^" + "@backstage/plugin-puppetdb": "workspace:^" + "@backstage/plugin-rollbar": "workspace:^" + "@backstage/plugin-scaffolder": "workspace:^" + "@backstage/plugin-scaffolder-react": "workspace:^" + "@backstage/plugin-search": "workspace:^" + "@backstage/plugin-search-common": "workspace:^" + "@backstage/plugin-search-react": "workspace:^" + "@backstage/plugin-sentry": "workspace:^" + "@backstage/plugin-shortcuts": "workspace:^" + "@backstage/plugin-stack-overflow": "workspace:^" + "@backstage/plugin-stackstorm": "workspace:^" + "@backstage/plugin-tech-insights": "workspace:^" + "@backstage/plugin-tech-radar": "workspace:^" + "@backstage/plugin-techdocs": "workspace:^" + "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^" + "@backstage/plugin-techdocs-react": "workspace:^" + "@backstage/plugin-todo": "workspace:^" + "@backstage/plugin-user-settings": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@internal/plugin-catalog-customized": "workspace:^" + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.61 + "@octokit/rest": ^19.0.3 + "@oriflame/backstage-plugin-score-card": ^0.7.0 + "@roadiehq/backstage-plugin-buildkite": ^2.0.8 + "@roadiehq/backstage-plugin-github-insights": ^2.0.5 + "@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7 + "@roadiehq/backstage-plugin-travis-ci": ^2.0.5 + "@testing-library/cypress": ^9.0.0 + "@testing-library/dom": ^8.0.0 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + "@types/jquery": ^3.3.34 + "@types/node": ^16.11.26 + "@types/react": "*" + "@types/react-dom": "*" + "@types/zen-observable": ^0.8.0 + cross-env: ^7.0.0 + cypress: ^10.0.0 + eslint-plugin-cypress: ^2.10.3 + history: ^5.0.0 + lodash: ^4.17.21 + prop-types: ^15.7.2 + react: ^17.0.2 + react-dom: ^17.0.2 + react-router: ^6.3.0 + react-router-dom: ^6.3.0 + react-use: ^17.2.4 + start-server-and-test: ^1.10.11 + zen-observable: ^0.10.0 + languageName: unknown + linkType: soft + "example-app@link:../app::locator=example-backend%40workspace%3Apackages%2Fbackend": version: 0.0.0-use.local resolution: "example-app@link:../app::locator=example-backend%40workspace%3Apackages%2Fbackend" From 9ebc561ca15b8613f63f08b126bd9b467e31721e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Jun 2023 11:42:31 +0200 Subject: [PATCH 10/22] app-next: working graphiql page 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: MT Lewis Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 5 +- packages/app-next/package.json | 2 +- packages/app-next/src/App.tsx | 287 +++++++++++++++++++++++------- 3 files changed, 228 insertions(+), 66 deletions(-) diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 6042c5ce2a..a15c053797 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -1,7 +1,10 @@ app: packages: 'all' # ✨ - extensions: [] + extensions: + - graphiql.page: + config: + path: / # - core.signInPage: # props: # provider: diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 35a8e02cbc..776e611c60 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -112,7 +112,7 @@ "start-server-and-test": "^1.10.11" }, "scripts": { - "start": "backstage-cli package start --config app-config.yaml --config packages/app-next/app-config.yaml", + "start": "backstage-cli package start --config ../../app-config.yaml --config app-config.yaml", "build": "backstage-cli package build", "clean": "backstage-cli package clean", "test": "backstage-cli package test", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index a93a934301..5d2a202667 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -13,28 +13,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AppRouter, FlatRoutes } from '@backstage/core-app-api'; -import { catalogPlugin } from '@internal/plugin-catalog-customized'; - -import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; -import { GraphiQLPage } from '@backstage/plugin-graphiql'; -import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { TechRadarPage } from '@backstage/plugin-tech-radar'; +import { + Router as GraphiQLPage, + graphiqlPlugin as legacyGraphiqlPlugin, +} from '@backstage/plugin-graphiql'; +import { createApp as createLegacyApp } from '@backstage/app-defaults'; import React, { ComponentType } from 'react'; -import { Route } from 'react-router-dom'; +import { BrowserRouter, useRoutes } from 'react-router-dom'; import mapValues from 'lodash/mapValues'; /* core */ -const discoverPackages = async () => { - // stub for now, deferring package discovery til later - return ['@backstage/plugin-graphiql']; -}; +// const discoverPackages = async () => { +// // stub for now, deferring package discovery til later +// return ['@backstage/plugin-graphiql']; +// }; -function createApp() {} +interface ExtensionInstanceConfig { + id: string; + at: string; + extension: Extension; + config: unknown; +} -function createPlugin() {} +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 ?? [], + }; +} type AnyExtensionDataMap = Record>; @@ -130,22 +150,157 @@ function createExtensionInstance(options: { return { id: options.id, data: extensionData, $$type: 'extension-instance' }; } -/* graphiql package */ +/* core extensions */ -const GraphiqlRoute = createExtension({ +const RouteExtension = createExtension({ inputs: { - extensionData: { - component: coreExtensionData.reactComponent, + routes: { + extensionData: { + path: coreExtensionData.routePath, + component: coreExtensionData.reactComponent, + }, }, }, - factory({ bind, points }) { - // ... + output: { + component: coreExtensionData.reactComponent, }, + factory({ bind, inputs }) { + const Routes = () => { + const element = useRoutes( + inputs.routes.map(route => ({ + path: route.path, + element: , + })), + ); + + return element; + }; + bind.component(() => ( + + + + )); + }, +}); + +function createApp(options: { plugins: BackstagePlugin[] }): { + createRoot(): JSX.Element; +} { + // 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 attachmentMap = new Map< + string, + Map + >(); + for (const config of extensionInstanceConfigs) { + const [extensionId, pointId = 'default'] = config.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(config); + } + + const instances = new Map(); + + function createInstance(config: ExtensionInstanceConfig): ExtensionInstance { + const existingInstance = instances.get(config.id); + if (existingInstance) { + return existingInstance; + } + + const attachments = Object.fromEntries( + Array.from(attachmentMap.get(config.id)?.entries() ?? []).map( + ([inputName, attachmentConfigs]) => [ + inputName, + attachmentConfigs.map(createInstance), + ], + ), + ); + + return createExtensionInstance({ + id: config.id, + config: config.config, + extension: config.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({ + plugins: [graphiqlPlugin], // bindRoutes({ bind }) { // bind(catalogPlugin.externalRoutes, { // createComponent: scaffolderPlugin.routes.root, @@ -156,50 +311,54 @@ const app = createApp({ // }, }); -const routes = ( - - {/* } /> - } /> - } - > - - - - - - +const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] }); - - - - - +export default legacyApp.createRoot(app.createRoot()); - - - - - - - } - /> */} - {/* } - /> */} - } /> - -); +// const routes = ( +// +// {/* } /> +// } /> +// } +// > +// +// +// +// +// +// -export default app.createRoot( - <> - {/* - */} - {routes} - , -); +// +// +// +// +// + +// +// +// +// +// +// +// } +// /> */} +// {/* } +// /> */} +// } /> +// +// ); + +// export default app.createRoot( +// <> +// {/* +// */} +// {routes} +// , +// ); From 3b102dc2aa8a7da17c44ba6d48e83dfda1b0a57c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Jun 2023 14:47:44 +0200 Subject: [PATCH 11/22] app-next: config reading and merging Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 1 + packages/app-next/src/App.tsx | 110 +++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index a15c053797..f1e46de905 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -2,6 +2,7 @@ app: packages: 'all' # ✨ extensions: + - core.router - graphiql.page: config: path: / diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 5d2a202667..41e0aae48b 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -22,6 +22,7 @@ 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'; /* core */ @@ -183,9 +184,70 @@ const RouteExtension = createExtension({ }, }); +// 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 = [ @@ -198,12 +260,40 @@ function createApp(options: { plugins: BackstagePlugin[] }): { }, ]; + 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 config of extensionInstanceConfigs) { - const [extensionId, pointId = 'default'] = config.at.split('/'); + for (const instanceConfig of extensionInstanceConfigs) { + const [extensionId, pointId = 'default'] = instanceConfig.at.split('/'); let pointMap = attachmentMap.get(extensionId); if (!pointMap) { @@ -217,19 +307,21 @@ function createApp(options: { plugins: BackstagePlugin[] }): { pointMap.set(pointId, instances); } - instances.push(config); + instances.push(instanceConfig); } const instances = new Map(); - function createInstance(config: ExtensionInstanceConfig): ExtensionInstance { - const existingInstance = instances.get(config.id); + function createInstance( + instanceConfig: ExtensionInstanceConfig, + ): ExtensionInstance { + const existingInstance = instances.get(instanceConfig.id); if (existingInstance) { return existingInstance; } const attachments = Object.fromEntries( - Array.from(attachmentMap.get(config.id)?.entries() ?? []).map( + Array.from(attachmentMap.get(instanceConfig.id)?.entries() ?? []).map( ([inputName, attachmentConfigs]) => [ inputName, attachmentConfigs.map(createInstance), @@ -238,9 +330,9 @@ function createApp(options: { plugins: BackstagePlugin[] }): { ); return createExtensionInstance({ - id: config.id, - config: config.config, - extension: config.extension, + id: instanceConfig.id, + config: instanceConfig.config, + extension: instanceConfig.extension, attachments, }); } From f77a25a047a641e1e85a9c7dc434f6469dce2511 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 Jun 2023 14:48:14 +0200 Subject: [PATCH 12/22] DO NOT SHIP: include all backend config in frontend Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 0c657b640e..69016728c1 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -106,7 +106,7 @@ export async function loadCliConfig(options: Options) { const frontendAppConfigs = schema.process(appConfigs, { visibility: options.fullVisibility ? ['frontend', 'backend', 'secret'] - : ['frontend'], + : ['frontend', 'backend'], withFilteredKeys: options.withFilteredKeys, withDeprecatedKeys: options.withDeprecatedKeys, ignoreSchemaErrors: !options.strict, From 7189abc8035f126ab0f7bc62618e654a6290c532 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 20 Jun 2023 18:11:00 +0200 Subject: [PATCH 13/22] app-next: add architecture diagram Signed-off-by: Patrik Oldsberg --- packages/app-next/architecture.drawio.svg | 614 ++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 packages/app-next/architecture.drawio.svg diff --git a/packages/app-next/architecture.drawio.svg b/packages/app-next/architecture.drawio.svg new file mode 100644 index 0000000000..b5ed4de71a --- /dev/null +++ b/packages/app-next/architecture.drawio.svg @@ -0,0 +1,614 @@ + + + + + + + + + + + + + + + + + + +
+
+
+ Extension Instance +
+
+
+
+ + Extension Ins... + +
+
+ + + + + + + +
+
+
+ Extension +
+
+
+
+ + Extension + +
+
+ + + + +
+
+
+ Output +
+
+
+
+ + Output + +
+
+ + + + + +
+
+
+ Input 1 +
+
+
+
+ + Input 1 + +
+
+ + + + + +
+
+
+ Input 2 +
+
+
+
+ + Input 2 + +
+
+ + + + + +
+
+
+ React Component +
+
+
+
+ + React Component + +
+
+ + + + + +
+
+
+ Route Path +
+
+
+
+ + Route Path + +
+
+ + + + + +
+
+
+ Display Title +
+
+
+
+ + Display Title + +
+
+ + + + + + +
+
+
+ Configuration +
+
+
+
+ + Configuration + +
+
+ + + + + + +
+
+
+ Parent +
+
+
+
+ + Parent + +
+
+ + + + + +
+
+
+ + Configuration Schema + +
+
+
+
+ + Configuration Sc... + +
+
+ + + + +
+
+
+ ID +
+
+
+
+ + ID + +
+
+ + + + + +
+
+
+ Multi Input Attachments +
+
+
+
+ + Multi Input Attachments + +
+
+ + + + +
+
+
+ Child +
+
+
+
+ + Child + +
+
+ + + + + + +
+
+
+ Child +
+
+
+
+ + Child + +
+
+ + + + + + + + + + + + + + +
+
+
+ Single Input Attachment +
+
+
+
+ + Single Input Attachm... + +
+
+ + + + +
+
+
+ Child +
+
+
+
+ + Child + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ Other +
+
+
+
+ + Other + +
+
+ + + + + + + + + + + + + + + +
+
+
+ Output Data +
+
+
+
+ + Output Data + +
+
+ + + + + +
+
+
+ Input Data +
+
+
+
+ + Input Data + +
+
+ + + + + + + + + +
+
+
+ Root +
+
+
+
+ + Root + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Example Extension Data Types + +
+
+
+
+ + Example Extension Data Types + +
+
+ + + + +
+
+
+ Extension Architecture +
+
+
+
+ + Extension Architecture + +
+
+ + + + +
+
+
+ App Example +
+
+
+
+ + App Example + +
+
+ + +
+ + + + + Text is not SVG - cannot display + + + +
From 7648aaf48d629955acb6a65254c3b588fa119ea1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 14:26:30 +0200 Subject: [PATCH 14/22] app-next: add TODOs + some reorder 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/src/App.tsx | 88 +++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 35 deletions(-) diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 41e0aae48b..c21d13c088 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -24,6 +24,24 @@ import { BrowserRouter, useRoutes } from 'react-router-dom'; import mapValues from 'lodash/mapValues'; import { Config, ConfigReader } from '@backstage/config'; +/* + +# Notes + +TODO: + - proper createApp + - connect extensions and plugins, provide method? + - higher level API for creating standard extensions + higher order framework API for creating those? + - extension config schema + validation + - figure out how to resolve configured extension ref to runtime value, e.g. '@backstage/plugin-graphiql#GraphiqlPage' + - make sure all shorthands work + tests + - figure out package structure / how to ship, frontend-plugin-api/frontend-app-api + - figure out routing, useRouteRef in the new system + - Legacy plugins / interop + - dynamic updates, runtime API + +*/ + /* core */ // const discoverPackages = async () => { @@ -31,31 +49,20 @@ import { Config, ConfigReader } from '@backstage/config'; // return ['@backstage/plugin-graphiql']; // }; -interface ExtensionInstanceConfig { +interface ExtensionDataRef { id: string; - at: string; - extension: Extension; - config: unknown; + T: T; + $$type: 'extension-data'; } -interface BackstagePluginOptions { - id: string; - defaultExtensionInstances?: ExtensionInstanceConfig[]; +function createExtensionDataRef(id: string) { + return { id, $$type: 'extension-data' } as ExtensionDataRef; } -interface BackstagePlugin { - $$type: 'backstage-plugin'; - id: string; - defaultExtensionInstances: ExtensionInstanceConfig[]; -} - -function createPlugin(options: BackstagePluginOptions): BackstagePlugin { - return { - ...options, - $$type: 'backstage-plugin', - defaultExtensionInstances: options.defaultExtensionInstances ?? [], - }; -} +const coreExtensionData = { + reactComponent: createExtensionDataRef('core.reactComponent'), + routePath: createExtensionDataRef('core.routing.path'), +}; type AnyExtensionDataMap = Record>; @@ -102,21 +109,6 @@ function createExtension< return { ...options, $$type: 'extension', inputs: options.inputs ?? {} }; } -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 ExtensionDataId = string; interface ExtensionInstance { @@ -151,6 +143,32 @@ function createExtensionInstance(options: { 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({ From c795a3ae0a3ad3f2cb6de6ae4c0f8556e971f377 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 14:29:38 +0200 Subject: [PATCH 15/22] add frontend-{app,plugin}-api packages 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/frontend-app-api/.eslintrc.js | 1 + packages/frontend-app-api/README.md | 12 +++++++ packages/frontend-app-api/api-report.md | 7 ++++ packages/frontend-app-api/package.json | 32 +++++++++++++++++++ packages/frontend-app-api/src/index.ts | 16 ++++++++++ packages/frontend-app-api/src/setupTests.ts | 16 ++++++++++ packages/frontend-plugin-api/.eslintrc.js | 1 + packages/frontend-plugin-api/README.md | 12 +++++++ packages/frontend-plugin-api/api-report.md | 7 ++++ packages/frontend-plugin-api/package.json | 32 +++++++++++++++++++ packages/frontend-plugin-api/src/index.ts | 16 ++++++++++ .../frontend-plugin-api/src/setupTests.ts | 16 ++++++++++ yarn.lock | 18 +++++++++++ 13 files changed, 186 insertions(+) create mode 100644 packages/frontend-app-api/.eslintrc.js create mode 100644 packages/frontend-app-api/README.md create mode 100644 packages/frontend-app-api/api-report.md create mode 100644 packages/frontend-app-api/package.json create mode 100644 packages/frontend-app-api/src/index.ts create mode 100644 packages/frontend-app-api/src/setupTests.ts create mode 100644 packages/frontend-plugin-api/.eslintrc.js create mode 100644 packages/frontend-plugin-api/README.md create mode 100644 packages/frontend-plugin-api/api-report.md create mode 100644 packages/frontend-plugin-api/package.json create mode 100644 packages/frontend-plugin-api/src/index.ts create mode 100644 packages/frontend-plugin-api/src/setupTests.ts diff --git a/packages/frontend-app-api/.eslintrc.js b/packages/frontend-app-api/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/frontend-app-api/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/frontend-app-api/README.md b/packages/frontend-app-api/README.md new file mode 100644 index 0000000000..5cc5073f61 --- /dev/null +++ b/packages/frontend-app-api/README.md @@ -0,0 +1,12 @@ +# @backstage/frontend-app-api + +_This package was created through the Backstage CLI_. + +## Installation + +Install the package via Yarn: + +```sh +cd # if within a monorepo +yarn add @backstage/frontend-app-api +``` diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md new file mode 100644 index 0000000000..d2a4d89246 --- /dev/null +++ b/packages/frontend-app-api/api-report.md @@ -0,0 +1,7 @@ +## API Report File for "@backstage/frontend-app-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json new file mode 100644 index 0000000000..9e10dfe0b6 --- /dev/null +++ b/packages/frontend-app-api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@backstage/frontend-app-api", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@testing-library/jest-dom": "^5.10.1" + }, + "files": [ + "dist" + ] +} diff --git a/packages/frontend-app-api/src/index.ts b/packages/frontend-app-api/src/index.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/packages/frontend-app-api/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 {}; diff --git a/packages/frontend-app-api/src/setupTests.ts b/packages/frontend-app-api/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/packages/frontend-app-api/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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 '@testing-library/jest-dom'; diff --git a/packages/frontend-plugin-api/.eslintrc.js b/packages/frontend-plugin-api/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/frontend-plugin-api/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/frontend-plugin-api/README.md b/packages/frontend-plugin-api/README.md new file mode 100644 index 0000000000..43cb4082dc --- /dev/null +++ b/packages/frontend-plugin-api/README.md @@ -0,0 +1,12 @@ +# @backstage/frontend-plugin-api + +_This package was created through the Backstage CLI_. + +## Installation + +Install the package via Yarn: + +```sh +cd # if within a monorepo +yarn add @backstage/frontend-plugin-api +``` diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md new file mode 100644 index 0000000000..2f86355ea7 --- /dev/null +++ b/packages/frontend-plugin-api/api-report.md @@ -0,0 +1,7 @@ +## API Report File for "@backstage/frontend-plugin-api" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json new file mode 100644 index 0000000000..fc49e443b1 --- /dev/null +++ b/packages/frontend-plugin-api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@backstage/frontend-plugin-api", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@testing-library/jest-dom": "^5.10.1" + }, + "files": [ + "dist" + ] +} diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/packages/frontend-plugin-api/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 {}; diff --git a/packages/frontend-plugin-api/src/setupTests.ts b/packages/frontend-plugin-api/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/packages/frontend-plugin-api/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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 '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index af8ced2b00..7cf063b616 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4060,6 +4060,24 @@ __metadata: languageName: unknown linkType: soft +"@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:^" + "@testing-library/jest-dom": ^5.10.1 + languageName: unknown + linkType: soft + +"@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 + languageName: unknown + linkType: soft + "@backstage/integration-aws-node@workspace:^, @backstage/integration-aws-node@workspace:packages/integration-aws-node": version: 0.0.0-use.local resolution: "@backstage/integration-aws-node@workspace:packages/integration-aws-node" From fe40e6de46cd76e3588e6d7e12c374bc602a74f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 14:49:29 +0200 Subject: [PATCH 16/22] 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:^" From 6ef12e1346ce5d2d59ac5dcf298e2e9c67934950 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 14:54:40 +0200 Subject: [PATCH 17/22] frontend-plugin-api: move extension instance creation to frontend-app-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/frontend-app-api/package.json | 7 +-- packages/frontend-app-api/src/createApp.tsx | 6 ++- .../src/createExtensionInstance.ts | 52 +++++++++++++++++++ packages/frontend-plugin-api/api-report.md | 21 -------- packages/frontend-plugin-api/src/index.ts | 3 -- packages/frontend-plugin-api/src/types.ts | 38 -------------- yarn.lock | 5 +- 7 files changed, 63 insertions(+), 69 deletions(-) create mode 100644 packages/frontend-app-api/src/createExtensionInstance.ts diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index ce68871133..c3564c31c4 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -32,10 +32,11 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/plugin-graphiql": "workspace:^" + "@backstage/plugin-graphiql": "workspace:^", + "lodash": "^4.17.21" }, "peerDependencies": { - "react": "*", - "react-router-dom": "*" + "react": "^16.13.1 || ^17.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" } } diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index 9c06ed2902..46cd0a7aed 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -19,11 +19,13 @@ import { Config, ConfigReader } from '@backstage/config'; import { ExtensionInstanceConfig, BackstagePlugin, - ExtensionInstance, - createExtensionInstance, coreExtensionData, } from '@backstage/frontend-plugin-api'; import { RouteExtension } from './extensions/RouteExtension'; +import { + createExtensionInstance, + ExtensionInstance, +} from './createExtensionInstance'; // 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 diff --git a/packages/frontend-app-api/src/createExtensionInstance.ts b/packages/frontend-app-api/src/createExtensionInstance.ts new file mode 100644 index 0000000000..0b62df74ae --- /dev/null +++ b/packages/frontend-app-api/src/createExtensionInstance.ts @@ -0,0 +1,52 @@ +/* + * 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 { Extension } from '@backstage/frontend-plugin-api'; +import mapValues from 'lodash/mapValues'; + +/** @internal */ +export interface ExtensionInstance { + id: string; + data: Map; + $$type: 'extension-instance'; +} + +/** @internal */ +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' }; +} diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 660662599b..4337b0b8c2 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -43,14 +43,6 @@ export function createExtension< >, >(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, @@ -106,9 +98,6 @@ 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; @@ -121,16 +110,6 @@ 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) diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index dbc5e130e7..e36391131d 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -22,18 +22,15 @@ 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/types.ts b/packages/frontend-plugin-api/src/types.ts index cb5bce0af9..b2d131c62f 100644 --- a/packages/frontend-plugin-api/src/types.ts +++ b/packages/frontend-plugin-api/src/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import mapValues from 'lodash/mapValues'; import { ComponentType } from 'react'; /** @public */ @@ -86,43 +85,6 @@ export function createExtension< 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; diff --git a/yarn.lock b/yarn.lock index 6e4b036475..0bfbe3d111 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4069,9 +4069,10 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-graphiql": "workspace:^" "@testing-library/jest-dom": ^5.10.1 + lodash: ^4.17.21 peerDependencies: - react: "*" - react-router-dom: "*" + react: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft From 1cd04493016a549896437ea3d0e621a3c08044e9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 15:00:18 +0200 Subject: [PATCH 18/22] remove app-experiments Signed-off-by: Patrik Oldsberg --- packages/app-experiments/.eslintrc.js | 1 - packages/app-experiments/README.md | 3 - packages/app-experiments/package.json | 142 ------ packages/app-experiments/src/Experiment1.tsx | 445 ------------------- packages/app-experiments/src/Experiment2.tsx | 186 -------- packages/app-experiments/src/Experiment3.tsx | 193 -------- packages/app-experiments/src/index.tsx | 22 - packages/app-experiments/src/setupTests.ts | 21 - yarn.lock | 109 ----- 9 files changed, 1122 deletions(-) delete mode 100644 packages/app-experiments/.eslintrc.js delete mode 100644 packages/app-experiments/README.md delete mode 100644 packages/app-experiments/package.json delete mode 100644 packages/app-experiments/src/Experiment1.tsx delete mode 100644 packages/app-experiments/src/Experiment2.tsx delete mode 100644 packages/app-experiments/src/Experiment3.tsx delete mode 100644 packages/app-experiments/src/index.tsx delete mode 100644 packages/app-experiments/src/setupTests.ts diff --git a/packages/app-experiments/.eslintrc.js b/packages/app-experiments/.eslintrc.js deleted file mode 100644 index e2a53a6ad2..0000000000 --- a/packages/app-experiments/.eslintrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/app-experiments/README.md b/packages/app-experiments/README.md deleted file mode 100644 index a2c7ae1b77..0000000000 --- a/packages/app-experiments/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# example-app - -This package is an example of a Backstage application. diff --git a/packages/app-experiments/package.json b/packages/app-experiments/package.json deleted file mode 100644 index af83b2e0e1..0000000000 --- a/packages/app-experiments/package.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "name": "app-experiments", - "version": "0.0.0", - "private": true, - "backstage": { - "role": "frontend" - }, - "bundled": true, - "dependencies": { - "@backstage/app-defaults": "workspace:^", - "@backstage/catalog-model": "workspace:^", - "@backstage/cli": "workspace:^", - "@backstage/config": "workspace:^", - "@backstage/core-app-api": "workspace:^", - "@backstage/core-components": "workspace:^", - "@backstage/core-plugin-api": "workspace:^", - "@backstage/integration-react": "workspace:^", - "@backstage/plugin-adr": "workspace:^", - "@backstage/plugin-airbrake": "workspace:^", - "@backstage/plugin-apache-airflow": "workspace:^", - "@backstage/plugin-api-docs": "workspace:^", - "@backstage/plugin-azure-devops": "workspace:^", - "@backstage/plugin-azure-sites": "workspace:^", - "@backstage/plugin-badges": "workspace:^", - "@backstage/plugin-catalog-common": "workspace:^", - "@backstage/plugin-catalog-graph": "workspace:^", - "@backstage/plugin-catalog-import": "workspace:^", - "@backstage/plugin-catalog-react": "workspace:^", - "@backstage/plugin-catalog-unprocessed-entities": "workspace:^", - "@backstage/plugin-circleci": "workspace:^", - "@backstage/plugin-cloudbuild": "workspace:^", - "@backstage/plugin-code-coverage": "workspace:^", - "@backstage/plugin-cost-insights": "workspace:^", - "@backstage/plugin-devtools": "workspace:^", - "@backstage/plugin-dynatrace": "workspace:^", - "@backstage/plugin-entity-feedback": "workspace:^", - "@backstage/plugin-explore": "workspace:^", - "@backstage/plugin-gcalendar": "workspace:^", - "@backstage/plugin-gcp-projects": "workspace:^", - "@backstage/plugin-github-actions": "workspace:^", - "@backstage/plugin-gocd": "workspace:^", - "@backstage/plugin-graphiql": "workspace:^", - "@backstage/plugin-home": "workspace:^", - "@backstage/plugin-jenkins": "workspace:^", - "@backstage/plugin-kafka": "workspace:^", - "@backstage/plugin-kubernetes": "workspace:^", - "@backstage/plugin-lighthouse": "workspace:^", - "@backstage/plugin-linguist": "workspace:^", - "@backstage/plugin-linguist-common": "workspace:^", - "@backstage/plugin-microsoft-calendar": "workspace:^", - "@backstage/plugin-newrelic": "workspace:^", - "@backstage/plugin-newrelic-dashboard": "workspace:^", - "@backstage/plugin-octopus-deploy": "workspace:^", - "@backstage/plugin-org": "workspace:^", - "@backstage/plugin-pagerduty": "workspace:^", - "@backstage/plugin-permission-react": "workspace:^", - "@backstage/plugin-playlist": "workspace:^", - "@backstage/plugin-puppetdb": "workspace:^", - "@backstage/plugin-rollbar": "workspace:^", - "@backstage/plugin-scaffolder": "workspace:^", - "@backstage/plugin-scaffolder-react": "workspace:^", - "@backstage/plugin-search": "workspace:^", - "@backstage/plugin-search-common": "workspace:^", - "@backstage/plugin-search-react": "workspace:^", - "@backstage/plugin-sentry": "workspace:^", - "@backstage/plugin-shortcuts": "workspace:^", - "@backstage/plugin-stack-overflow": "workspace:^", - "@backstage/plugin-stackstorm": "workspace:^", - "@backstage/plugin-tech-insights": "workspace:^", - "@backstage/plugin-tech-radar": "workspace:^", - "@backstage/plugin-techdocs": "workspace:^", - "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^", - "@backstage/plugin-techdocs-react": "workspace:^", - "@backstage/plugin-todo": "workspace:^", - "@backstage/plugin-user-settings": "workspace:^", - "@backstage/theme": "workspace:^", - "@internal/plugin-catalog-customized": "workspace:^", - "@material-ui/core": "^4.12.2", - "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.61", - "@octokit/rest": "^19.0.3", - "@oriflame/backstage-plugin-score-card": "^0.7.0", - "@roadiehq/backstage-plugin-buildkite": "^2.0.8", - "@roadiehq/backstage-plugin-github-insights": "^2.0.5", - "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", - "@roadiehq/backstage-plugin-travis-ci": "^2.0.5", - "history": "^5.0.0", - "lodash": "^4.17.21", - "prop-types": "^15.7.2", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-router": "^6.3.0", - "react-router-dom": "^6.3.0", - "react-use": "^17.2.4", - "zen-observable": "^0.10.0", - "zod": "^3.21.4" - }, - "devDependencies": { - "@backstage/test-utils": "workspace:^", - "@testing-library/cypress": "^9.0.0", - "@testing-library/dom": "^8.0.0", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", - "@testing-library/user-event": "^14.0.0", - "@types/jquery": "^3.3.34", - "@types/node": "^16.11.26", - "@types/react": "*", - "@types/react-dom": "*", - "@types/zen-observable": "^0.8.0", - "cross-env": "^7.0.0", - "cypress": "^10.0.0", - "eslint-plugin-cypress": "^2.10.3", - "start-server-and-test": "^1.10.11" - }, - "scripts": { - "start": "backstage-cli package start", - "build": "backstage-cli package build", - "clean": "backstage-cli package clean", - "test": "backstage-cli package test", - "lint": "backstage-cli package lint", - "test:e2e": "start-server-and-test start http://localhost:3000 cy:dev", - "test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run", - "cy:dev": "cypress open", - "cy:run": "cypress run" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - }, - "files": [ - "dist" - ], - "license": "Apache-2.0" -} diff --git a/packages/app-experiments/src/Experiment1.tsx b/packages/app-experiments/src/Experiment1.tsx deleted file mode 100644 index 0b5496f431..0000000000 --- a/packages/app-experiments/src/Experiment1.tsx +++ /dev/null @@ -1,445 +0,0 @@ -/* - * 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, { CSSProperties, ComponentType, createContext } from 'react'; -import { z } from 'zod'; -import mapValues from 'lodash/mapValues'; -import { Typography } from '@material-ui/core'; - -/* - -App structure: - - root - - red - - green - - blue - -*/ -// app.registerExtensionPoint() - -// import { redExtensionPointRef } from 'wherever'; - -type Extension = { - factory(instanceOptions: { id: string; config: TInstanceConfig }): TSelf; -}; - -type ExtensionOptions< - TInstanceConfig, - TSelf, - TPoints extends { - [name in string]: { - typeRef: ExtensionPointTypeRef; - configSchema: z.ZodObject; - }; - }, -> = { - mountTypeRef: ExtensionPointTypeRef; - points: TPoints; - // points: { - // [name in keyof TPoints]: { - // typeRef: TPoints[name]['typeRef']; - // configSchema: z.ZodObject; - // }; - // }; - factory(instanceOptions: { - id: string; - config: TInstanceConfig; - points: { - [name in keyof TPoints]: ExtensionPointRef; - }; - // points: {[name in keyof TPoints]: TPoints[name] extends ExtensionPointTypeRef ? ExtensionPointRef : never}; - }): TSelf; -}; - -// points: { -// top: { ref: componentExtensionPointTypeRef, configSchema: z.object({}) }, -// bottom: { ref: componentExtensionPointTypeRef, configSchema: z.object({}) }, -// }, - -type ExtensionInstanceConfig = { - id: string; - config: TInstanceConfig; - // what should point be for root - mount?: string; - extension: Extension; -}; - -type ExtensionInstance = { - id: string; - // what should point be for root - mount?: string; - output: TSelf; -}; - -const BackstageAppContext = createContext<{ - extensionInstances: ExtensionInstance[]; -}>({ extensionInstances: [] }); - -const container = { - createExtension< - TInstanceConfig, - TSelf, - TPoints extends { - [name in string]: { - typeRef: ExtensionPointTypeRef; - configSchema: z.ZodObject; - }; - }, - >( - extensionOptions: ExtensionOptions, - ): Extension { - return { - factory(instanceOptions) { - return extensionOptions.factory({ - id: instanceOptions.id, - config: instanceOptions.config, - points: mapValues(extensionOptions.points, (point, name) => - createExtensionPointRef(name, point.typeRef), - ), - }); - }, - }; - }, -}; - -function useExtensionInstanceChildren( - id: string, - point: ExtensionPointRef, -): T[] { - const { extensionInstances } = React.useContext(BackstageAppContext); - - return extensionInstances - .filter(i => i.mount === `${id}/${point.name}`) - .map(extensionInstance => extensionInstance.output as T); -} - -const ExtensionInstanceChildren = (props: { - id: string; - point: ExtensionPointRef; -}) => { - const outputs = useExtensionInstanceChildren(props.id, props.point); - - return ( - <> - {outputs.map(Component => ( - - ))} - - ); -}; - -export const coreExtensionPointTypes = { - component: createExtensionPointTypeRef(), - styles: createExtensionPointTypeRef(), -}; - -const Container = container.createExtension({ - points: { - default: { - typeRef: coreExtensionPointTypes.component, - configSchema: z.object({}), - }, - }, - mountTypeRef: coreExtensionPointTypes.component, - factory: - ({ id, points }) => - () => - , -}); - -interface ExtensionPointTypeRef { - T: T; - $$type: 'extension-point-type'; -} - -function createExtensionPointTypeRef(): ExtensionPointTypeRef { - return { T: null as T, $$type: 'extension-point-type' }; -} - -interface ExtensionPointRef { - name: string; - typeRef: ExtensionPointTypeRef; - $$type: 'extension-point'; -} - -function createExtensionPointRef( - name: string, - typeRef: ExtensionPointTypeRef, -): ExtensionPointRef { - return { name, typeRef, $$type: 'extension-point' }; -} - -// const splitLayoutTopExtensionPointRef = createExtensionPointRef({ -// type: componentExtensionPointTypeRef, -// point: 'top' -// }) - -// const splitLayoutBottomExtensionPointRef = createExtensionPointRef({ -// type: componentExtensionPointTypeRef, -// point: 'bottom' -// }) - -const gridItemExtensionPointTypeRef = createExtensionPointTypeRef<{ - title: string; - Component: ComponentType; -}>(); - -const SplitGridLayout = container.createExtension({ - points: { - top: { - typeRef: gridItemExtensionPointTypeRef, - configSchema: z.object({}), - }, - bottom: { - typeRef: gridItemExtensionPointTypeRef, - configSchema: z.object({}), - }, - }, - mountTypeRef: coreExtensionPointTypes.component, - factory: - ({ id, points }) => - () => { - const topCards = useExtensionInstanceChildren(id, points.top); - const bottomCards = useExtensionInstanceChildren(id, points.bottom); - return ( -
-
- {topCards.map(card => ( - <> -

{card.title}

- - - ))} -
-
- {bottomCards.map(card => ( - <> -

{card.title}

- - - ))} -
-
- ); - }, -}); - -// const SnazzySplitLayout = container.replaceExtension(SplitGridLayout, { -// factory: ({ id, points }) => { -// ... -// } -// }) - -const Box = container.createExtension({ - points: {}, - mountTypeRef: gridItemExtensionPointTypeRef, - factory: ({ config }: { config: { color: string; title?: string } }) => { - const Component = () => ( - /** - * If I need stuff, I have to get it from context. - */ -
- {config.color} -
- ); - // registerOutput(gridItemExtensionPointTypeRef, {title: config.title ?? ''}) - // registerOutput(coreExtensionPointTypes.component, Component) - return { Component, title: config.title ?? '' }; - }, -}); - -const StyledBox = container.createExtension({ - points: { - style: { - typeRef: coreExtensionPointTypes.styles, - configSchema: z.object({}), - }, - }, - mountTypeRef: gridItemExtensionPointTypeRef, - factory: ({ id, points }) => { - const Component = () => { - const [style] = useExtensionInstanceChildren(id, points.style); - - return
Styled box
; - }; - return { Component, title: 'derp' }; - }, -}); - -const BoxStyle = container.createExtension({ - points: {}, - mountTypeRef: coreExtensionPointTypes.styles, - factory({ config }: { config: { color: string } }) { - return { - borderColor: config.color, - borderStyle: 'solid', - borderWidth: '5px', - width: 120, - height: 120, - }; - }, -}); - -function createExtensionInstances( - instanceConfigs: ExtensionInstanceConfig[], -): ExtensionInstance[] { - return instanceConfigs.map(instanceConfig => ({ - id: instanceConfig.id, - mount: instanceConfig.mount, - output: instanceConfig.extension.factory({ - id: instanceConfig.id, - config: instanceConfig.config, - }), - })); -} - -const ExtensionInstanceRenderer = (props: { id: string }) => { - const { id } = props; - const { extensionInstances } = React.useContext(BackstageAppContext); - - const value = extensionInstances.find(i => i.id === id); - - if (!value) { - throw new Error(`No extension instance found with id ${id}`); - } - - // TODO: Validation? - const ComponentInstance = value.output as React.ComponentType; - - return ; -}; - -export function Experiment1() { - const extensionInstances = createExtensionInstances([ - { id: 'root', config: {}, extension: Container }, - { - id: 'layout', - mount: 'root/default', // Maybe can omit /default here? - config: {}, - extension: SplitGridLayout, - }, - { - id: 'red', - mount: 'layout/top', - config: { color: 'red', title: 'RED' }, - extension: Box, - }, - { - id: 'green', - mount: 'layout/top', - config: { color: 'green', title: 'GREEN' }, - extension: Box, - }, - { - id: 'blue', - mount: 'layout/bottom', - config: { color: 'blue', title: 'BLUE' }, - extension: Box, - }, - { - id: 'styled-box', - mount: 'layout/bottom', - config: {}, - extension: StyledBox, - }, - { - id: 'styled-box.style', - mount: 'styled-box/style', - config: { color: 'purple' }, - extension: BoxStyle, - }, - // { - // id: 'catalogPage', - // mount: 'core/routes', - // config: { path: '/catalog' }, - // extension: Route, - // }, - ]); - - return ( - -

Experiment 1

- - This is an experiment to see how the app-experiments package works. - - -
- ); -} - -/** - * Continued exploration: - * - * - routing - * - dynamic extension points - * - mount point config - * - declarative mixed with code, e.g. - * - lazy loading - * - defaults - */ - -/* -// graphiql-plugin - - - -// App.tsx - -export default app.createRoot(); -export default app.createRoot(extensionInstanceElement('root')); - -function extensionInstanceElement(id: string): JSX.Element { -} - -app: - extensions: - - root.sidebar: - use: 'CustomSidebar' - - -function CustomSidebar() { - return ( - - {extensionInstanceElement('sidebar.items.derp')} - - - ) -} - -const customSidebar = createComponentExtension({ - component: CustomSidebar -}) - -createApp({ - extensions: [customSidebar] -}) - -*/ diff --git a/packages/app-experiments/src/Experiment2.tsx b/packages/app-experiments/src/Experiment2.tsx deleted file mode 100644 index ef803803c8..0000000000 --- a/packages/app-experiments/src/Experiment2.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* - * 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, { ComponentType } from 'react'; -import mapValues from 'lodash/mapValues'; - -/** - * App structure - * - Root - * - GroceryLayout (fruit} - * - Apple [grocery, fruit, component] - * - Orange [grocery, fruit, component] - * - Metallica [band] - */ - -interface ExtensionDataRef { - id: string; - T: T; - $$type: 'extension-data'; -} - -function createExtensionDataRef(id: string) { - return { id } as ExtensionDataRef; -} - -const coreExtensionData = { - reactComponent: createExtensionDataRef('core.reactComponent'), - - isGrocery: createExtensionDataRef('isGrocery'), - isFruit: createExtensionDataRef('isFruit'), - - isBand: createExtensionDataRef('isBand'), - title: createExtensionDataRef('core.title'), -}; - -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, -> { - extensionData: TData; - points?: TPoint; - factory(options: { - bind: ExtensionDataBind; - config?: unknown; - points: { - [pointName in keyof TPoint]: ExtensionDataValue< - TPoint[pointName]['extensionData'] - >[]; - }; - }): void; -} - -function createExtension< - TData extends AnyExtensionDataMap, - TPoint extends Record, ->(options: CreateExtensionOptions) { - return options; -} - -const GroceryLayout = createExtension({ - points: { - groceries: { - // requiredData: [ - // coreExtensionData.reactComponent, - // coreExtensionData.isGrocery, - // ], - extensionData: { - Component: coreExtensionData.reactComponent, - isGrocery: coreExtensionData.isGrocery, - }, - }, - }, - extensionData: { - component: coreExtensionData.reactComponent, - }, - factory({ bind, points }) { - const elements = points.groceries.map(point => ); - /** - * Probably easier to evolve over time - * const elements = points.groceries.map((point) => getExtensionData(point, coreExtensionData.reactComponent)) - **/ - const Component = () =>
yo yo yo, here's my elements: {elements}
; - bind.component(Component); - }, -}); - -const Apple = createExtension({ - extensionData: { - component: coreExtensionData.reactComponent, - isGrocery: coreExtensionData.isGrocery, - isFruit: coreExtensionData.isFruit, - }, - factory({ bind }) { - const Component = () =>
apple
; - bind.component(Component); - bind.isGrocery(true); - bind.isFruit(true); - }, -}); - -const Metallica = createExtension({ - extensionData: { - component: coreExtensionData.reactComponent, - }, - factory({ bind }) { - const Component = () =>
HEAVY METAL YEAH
; - bind.component(Component); - }, -}); - -const Orange = createExtension({ - extensionData: { - component: coreExtensionData.reactComponent, - isGrocery: coreExtensionData.isGrocery, - isFruit: coreExtensionData.isFruit, - }, - factory({ bind }) { - const Component = () =>
orange
; - bind.component(Component); - bind.isGrocery(true); - bind.isFruit(true); - }, -}); - -function createExtensionInstance( - options: CreateExtensionOptions< - AnyExtensionDataMap, - Record - >, - children: { data: Map, unknown> }[], -) { - const extensionData = new Map, unknown>(); - options.factory({ - bind: mapValues(options.extensionData, ref => { - return (value: unknown) => extensionData.set(ref, value); - }), - points: mapValues(options.points, ({ extensionData: pointData }) => { - const requiredRef = Object.values(pointData); - const matchingChildren = children.filter(child => - requiredRef.every(ref => child.data.has(ref)), - ); - return matchingChildren.map(child => - mapValues(pointData, ref => child.data.get(ref)), - ); - }), - }); - return { data: extensionData }; -} - -export function Experiment2() { - const apple = createExtensionInstance(Apple, []); - const orange = createExtensionInstance(Orange, []); - const metallica = createExtensionInstance(Metallica, []); - const layout = createExtensionInstance(GroceryLayout, [ - apple, - orange, - metallica, - ]); - const Component = layout.data.get( - coreExtensionData.reactComponent, - ) as ComponentType; - return ; -} diff --git a/packages/app-experiments/src/Experiment3.tsx b/packages/app-experiments/src/Experiment3.tsx deleted file mode 100644 index e0cdd96af4..0000000000 --- a/packages/app-experiments/src/Experiment3.tsx +++ /dev/null @@ -1,193 +0,0 @@ -/* - * 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. - */ - -type Extension = { - factory(instanceOptions: { id: string; config: TInstanceConfig }): TSelf; -}; - -type ExtensionInstance = { - id: string; - // what should point be for root - mount?: string; - output: TSelf; -}; - -const ExtensionInstanceRenderer = (props: { id: string }) => { - const { id } = props; - const { extensionInstances } = React.useContext(BackstageAppContext); - const value = extensionInstances.find(i => i.id === id); - if (!value) { - throw new Error(`No extension instance found with id ${id}`); - } - const ComponentInstance = value.output as React.ComponentType; - return ; -}; - -export function Experiment1() { - const extensionInstances = createExtensionInstances([ - { - id: 'layout', - parent: 'root', // Maybe can omit /default here? - config: {}, - use: SplitGridLayout, - }, - { - id: 'red', - mount: 'layout/top', - config: { color: 'red', title: 'RED' }, - use: Box, - }, - { - // green is a Box on layout/top with color=green title=GREEN - // alias, name, id, ... - // green is a Box extension attached to the `layout` extension instance on the `top` - // extension point with config { ... }. - id: 'green', - // at: { id: 'layout', point: 'top' }, - at: 'layout/top', - extension: '@backstage/plugin-catalog-react#Box', - config: { color: 'green', title: 'GREEN' }, - }, - { - id: 'blue', - at: 'layout/bottom', - extension: '@backstage/plugin-catalog-react#Box', - config: { color: 'blue', title: 'BLUE' }, - }, - - { - id: 'styled-box', - parent: 'layout/bottom', - config: { title: 'STYLED' }, - extension: StyledBox, - }, - { - id: 'styled-box.style', - parent: 'styled-box/style', - config: { color: 'purple' }, - extension: BoxStyle, - }, - - { - id: 'scaffolder.page', - bind: 'core.router/routes', - attach: 'core.router/routes', - target: 'core.router/routes', - connect: 'core.router/routes', - on: 'core.router/routes', - at: 'core.router/routes', - edge: 'core.router/routes', - config: { path: '/create' }, - extension: ScaffolderPage, - }, - { - id: 'scaffolder.fieldExtensions.derp', - attach: 'scaffolder.page/fieldExtension', - config: { color: 'purple' }, - extension: DerpScaffolderFieldExtension, - }, - ]); -} - -interface ExtensionDataRef { - id: string; - T: T; - $$type: 'extension-data'; -} - -function createExtensionDataRef(id: string) { - return { id } as ExtensionDataRef; -} - -const coreExtensionData = { - reactComponent: createExtensionDataRef('core.reactComponent'), - isGrocery: createExtensionDataRef('isGrocery'), - title: createExtensionDataRef('core.title'), -}; - -const groceryExtensionDataCollection = () => ({ - Component: coreExtensionData.reactComponent, - isGrocery: coreExtensionData.isGrocery, -}); - -const groceriesExtensionPoint = createExtensionPoint({ - extensionData: groceryExtensionDataCollection(), -}); - -const GroceryLayout = createExtension({ - points: { - groceries: groceriesExtensionPoint, - // groceries: { - // extensionData: groceryExtensionDataCollection(), - // }, - }, - extensionData: { - component: coreExtensionData.reactComponent, - }, - factory({ bind, points }) { - const elements = points.groceries.map(point => ); - const Component = () =>
yo yo yo, here's my elements: {elements}
; - bind.component(Component); - }, -}); - -// // plugin-*-react -// const GroceryLayoutExtensionDefinition = defineExtension({ -// input: { -// groceries: { -// component: coreExtensionData.reactComponent, -// isGrocery: coreExtensionData.isGrocery, -// } -// }, -// output: { -// component: coreExtensionData.reactComponent, -// }, -// }); - -// // plugin-* -// const GroceryLayoutExtension = createExtension(GroceryLayoutExtension, ({ bind, points }) => { -// ... -// }); - -const GroceryLayout = createExtension({ - inputs: { - groceries: { - Component: coreExtensionData.reactComponent, - isGrocery: coreExtensionData.isGrocery, - }, - }, - output: { - component: coreExtensionData.reactComponent, - }, - factory({ output, input }) { - const elements = input.groceries.map(point => ); - const Component = () =>
yo yo yo, here's my elements: {elements}
; - output.component.set({ component: Component }); - }, -}); - -const createGroceryExtension = createExtensionFactory(({ component }) => ({ - extensionData: groceryExtensionDataCollection(), - factory({ bind }) { - bind.component(component); - bind.isGrocery(true); - bind.isFruit(true); - }, -})); - -const Apple = createGroceryExtension({ - component: () =>
apple
, -}); diff --git a/packages/app-experiments/src/index.tsx b/packages/app-experiments/src/index.tsx deleted file mode 100644 index 3abd870c17..0000000000 --- a/packages/app-experiments/src/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020 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 '@backstage/cli/asset-types'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Experiment2 } from './Experiment2'; - -ReactDOM.render(, document.getElementById('root')); diff --git a/packages/app-experiments/src/setupTests.ts b/packages/app-experiments/src/setupTests.ts deleted file mode 100644 index 23fcbe9676..0000000000 --- a/packages/app-experiments/src/setupTests.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2020 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. - */ - -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 0bfbe3d111..44e6c79d58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19287,115 +19287,6 @@ __metadata: languageName: node linkType: hard -"app-experiments@workspace:packages/app-experiments": - version: 0.0.0-use.local - resolution: "app-experiments@workspace:packages/app-experiments" - dependencies: - "@backstage/app-defaults": "workspace:^" - "@backstage/catalog-model": "workspace:^" - "@backstage/cli": "workspace:^" - "@backstage/config": "workspace:^" - "@backstage/core-app-api": "workspace:^" - "@backstage/core-components": "workspace:^" - "@backstage/core-plugin-api": "workspace:^" - "@backstage/integration-react": "workspace:^" - "@backstage/plugin-adr": "workspace:^" - "@backstage/plugin-airbrake": "workspace:^" - "@backstage/plugin-apache-airflow": "workspace:^" - "@backstage/plugin-api-docs": "workspace:^" - "@backstage/plugin-azure-devops": "workspace:^" - "@backstage/plugin-azure-sites": "workspace:^" - "@backstage/plugin-badges": "workspace:^" - "@backstage/plugin-catalog-common": "workspace:^" - "@backstage/plugin-catalog-graph": "workspace:^" - "@backstage/plugin-catalog-import": "workspace:^" - "@backstage/plugin-catalog-react": "workspace:^" - "@backstage/plugin-catalog-unprocessed-entities": "workspace:^" - "@backstage/plugin-circleci": "workspace:^" - "@backstage/plugin-cloudbuild": "workspace:^" - "@backstage/plugin-code-coverage": "workspace:^" - "@backstage/plugin-cost-insights": "workspace:^" - "@backstage/plugin-devtools": "workspace:^" - "@backstage/plugin-dynatrace": "workspace:^" - "@backstage/plugin-entity-feedback": "workspace:^" - "@backstage/plugin-explore": "workspace:^" - "@backstage/plugin-gcalendar": "workspace:^" - "@backstage/plugin-gcp-projects": "workspace:^" - "@backstage/plugin-github-actions": "workspace:^" - "@backstage/plugin-gocd": "workspace:^" - "@backstage/plugin-graphiql": "workspace:^" - "@backstage/plugin-home": "workspace:^" - "@backstage/plugin-jenkins": "workspace:^" - "@backstage/plugin-kafka": "workspace:^" - "@backstage/plugin-kubernetes": "workspace:^" - "@backstage/plugin-lighthouse": "workspace:^" - "@backstage/plugin-linguist": "workspace:^" - "@backstage/plugin-linguist-common": "workspace:^" - "@backstage/plugin-microsoft-calendar": "workspace:^" - "@backstage/plugin-newrelic": "workspace:^" - "@backstage/plugin-newrelic-dashboard": "workspace:^" - "@backstage/plugin-octopus-deploy": "workspace:^" - "@backstage/plugin-org": "workspace:^" - "@backstage/plugin-pagerduty": "workspace:^" - "@backstage/plugin-permission-react": "workspace:^" - "@backstage/plugin-playlist": "workspace:^" - "@backstage/plugin-puppetdb": "workspace:^" - "@backstage/plugin-rollbar": "workspace:^" - "@backstage/plugin-scaffolder": "workspace:^" - "@backstage/plugin-scaffolder-react": "workspace:^" - "@backstage/plugin-search": "workspace:^" - "@backstage/plugin-search-common": "workspace:^" - "@backstage/plugin-search-react": "workspace:^" - "@backstage/plugin-sentry": "workspace:^" - "@backstage/plugin-shortcuts": "workspace:^" - "@backstage/plugin-stack-overflow": "workspace:^" - "@backstage/plugin-stackstorm": "workspace:^" - "@backstage/plugin-tech-insights": "workspace:^" - "@backstage/plugin-tech-radar": "workspace:^" - "@backstage/plugin-techdocs": "workspace:^" - "@backstage/plugin-techdocs-module-addons-contrib": "workspace:^" - "@backstage/plugin-techdocs-react": "workspace:^" - "@backstage/plugin-todo": "workspace:^" - "@backstage/plugin-user-settings": "workspace:^" - "@backstage/test-utils": "workspace:^" - "@backstage/theme": "workspace:^" - "@internal/plugin-catalog-customized": "workspace:^" - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - "@material-ui/lab": 4.0.0-alpha.61 - "@octokit/rest": ^19.0.3 - "@oriflame/backstage-plugin-score-card": ^0.7.0 - "@roadiehq/backstage-plugin-buildkite": ^2.0.8 - "@roadiehq/backstage-plugin-github-insights": ^2.0.5 - "@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7 - "@roadiehq/backstage-plugin-travis-ci": ^2.0.5 - "@testing-library/cypress": ^9.0.0 - "@testing-library/dom": ^8.0.0 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 - "@types/jquery": ^3.3.34 - "@types/node": ^16.11.26 - "@types/react": "*" - "@types/react-dom": "*" - "@types/zen-observable": ^0.8.0 - cross-env: ^7.0.0 - cypress: ^10.0.0 - eslint-plugin-cypress: ^2.10.3 - history: ^5.0.0 - lodash: ^4.17.21 - prop-types: ^15.7.2 - react: ^17.0.2 - react-dom: ^17.0.2 - react-router: ^6.3.0 - react-router-dom: ^6.3.0 - react-use: ^17.2.4 - start-server-and-test: ^1.10.11 - zen-observable: ^0.10.0 - zod: ^3.21.4 - languageName: unknown - linkType: soft - "append-field@npm:^1.0.0": version: 1.0.0 resolution: "append-field@npm:1.0.0" From 0d7a5b1cede40bf6aa61b12469f64c93ebb9120a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 15:56:32 +0200 Subject: [PATCH 19/22] cli: remove config hack Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 69016728c1..0c657b640e 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -106,7 +106,7 @@ export async function loadCliConfig(options: Options) { const frontendAppConfigs = schema.process(appConfigs, { visibility: options.fullVisibility ? ['frontend', 'backend', 'secret'] - : ['frontend', 'backend'], + : ['frontend'], withFilteredKeys: options.withFilteredKeys, withDeprecatedKeys: options.withDeprecatedKeys, ignoreSchemaErrors: !options.strict, From 0cb0ac517be2e012e9f7b47d0e99cacb18eb6d6c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 16:43:53 +0200 Subject: [PATCH 20/22] frontend-app-api: added config schema Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/config.d.ts | 35 ++++++++++++++++++++++++++ packages/frontend-app-api/package.json | 2 ++ 2 files changed, 37 insertions(+) create mode 100644 packages/frontend-app-api/config.d.ts diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts new file mode 100644 index 0000000000..81c24d1185 --- /dev/null +++ b/packages/frontend-app-api/config.d.ts @@ -0,0 +1,35 @@ +/* + * 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 interface Config { + app?: { + /** + * @deepVisibility frontend + */ + extensions?: + | string + | { + [extensionId: string]: + | boolean + | string + | { + at?: string; + extension?: string; + config?: unknown; + }; + }; + }; +} diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index c3564c31c4..c085c6dba6 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -26,7 +26,9 @@ "@backstage/cli": "workspace:^", "@testing-library/jest-dom": "^5.10.1" }, + "configSchema": "config.d.ts", "files": [ + "config.d.ts", "dist" ], "dependencies": { From f46953c850ea7a5b3e060409a2d8958c51a68131 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 17:49:11 +0200 Subject: [PATCH 21/22] app-next: fix test Signed-off-by: Patrik Oldsberg --- packages/app-next/src/App.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-next/src/App.test.tsx b/packages/app-next/src/App.test.tsx index 8c7cefeb2c..047c9821b5 100644 --- a/packages/app-next/src/App.test.tsx +++ b/packages/app-next/src/App.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { renderWithEffects } from '@backstage/test-utils'; -import App from './App'; describe('App', () => { it('should render', async () => { @@ -42,6 +41,7 @@ describe('App', () => { ] as any, }; + const { default: App } = await import('./App'); const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); }); From c88381247100076681e1e669a27c772cdeb127ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 21 Aug 2023 18:16:33 +0200 Subject: [PATCH 22/22] app-next: app-config format Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index f1e46de905..463113fd55 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -1,4 +1,3 @@ - app: packages: 'all' # ✨ extensions: @@ -6,6 +5,7 @@ app: - graphiql.page: config: path: / + # - core.signInPage: # props: # provider: