Merge branch 'backstage:master' into patch-1

This commit is contained in:
John Kriter
2023-10-23 08:25:21 -06:00
committed by GitHub
253 changed files with 10981 additions and 3174 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/config-loader': patch
---
Correctly resolve config targets into absolute paths
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
Create an experimental plugin that is compatible with the declarative integration system, it is exported from the `/alpha` subpath.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-stack-overflow-collator': minor
---
Extract a package for the Stack Overflow new backend system plugin.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix `RoutedTabs` so that it does not explode without tabs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Switch from using deprecated `@esbuild-kit/*` packages to using `tsx`. This also switches to using the new module loader `register` API when available, avoiding the experimental warning when starting backends.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Migrate catalog entity cards to new frontend system extension format.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-node': patch
---
Fix highlighting for non-string fields on the `Lunr` search engine implementation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs-module-addons-contrib': patch
'@backstage/plugin-techdocs': patch
---
Fixed navigation bug that caused users to not be scrolled to the top of a new page. Fixed navigation bug where using backwards and forwards browser navigation did not scroll users to the correct place on the TechDoc page.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
Added try catch around fetching gitlab group users to prevent refresh from failing completely while only a select number of groups might not be able to load correctly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Export alpha routes and nav item extension, only available for applications that uses the new Frontend system.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-stack-overflow-backend': patch
---
Deprecate package in favor of the new `@backstage/plugin-search-backend-module-stack-overflow-collator` module.
The search collator `requestParams` option is optional now, so its default value is `{ order: 'desc', sort: 'activity', site: 'stackoverflow' }` as defined in the `Try It` section on the [official Stack Overflow API documentation](https://api.stackexchange.com/docs/questions).
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Refactor internal extension instance system into an app graph.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Add info about the entity when tech docs fail to build
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-code-coverage-backend': patch
---
Added support for new backend system
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add examples for `github:environment:create` scaffolder action & improve related tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Initial entity page implementation for new frontend system at `/alpha`, with an overview page enabled by default and the about card available as an optional card.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Added entity page content for the new plugin exported via `/alpha`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-stack-overflow': patch
---
Migrate package to the new Frontend system, the new module is distributed with a `/alpha` subpath.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Added new APIs at the `/alpha` subpath for creating entity page cards and content for the new frontend system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Updated `app.extensions` configuration schema.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-tech-insights-backend': patch
---
Add `factRetrieverId` to the fact retriever's logger metadata.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-azure-devops': patch
---
Consolidated getting the annotation values into a single function to help with future changes
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/dev-utils': patch
'@backstage/plugin-techdocs': patch
---
Switched the conditional `react-dom/client` import to use `import(...)` rather than `require(...)`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Fixed a rare occurrence where a race in the search bar could throw away user input or cause the clear button not to work.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
If create app installs dependencies, don't suggest to user that they also need to do it.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
The experimental package detection will now ignore packages that don't make `package.json` available.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-react': minor
---
The `UserListPicker` component has undergone improvements to enhance its performance.
The previous implementation inferred the number of owned and starred entities based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling.
The component now loads the entities' count asynchronously, resulting in improved performance and responsiveness. For this purpose, some of the exported filters such as `EntityTagFilter`, `EntityOwnerFilter`, `EntityLifecycleFilter` and `EntityNamespaceFilter` have now the `getCatalogFilters` method implemented.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Improve the extension boundary component and create a default extension suspense component.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Reverted the Microsoft auth provider to the previous implementation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Added a workaround for double `default` wrapping when dynamically importing CommonJS modules with default exports.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add examples for `github:webhook` scaffolder action & improve related tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Added `EXPERIMENTAL_VITE` flag for using [vite](https://vitejs.dev) as dev server instead of Webpack
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Allow setting `update: true` in `publish:github:pull-request` scaffolder action
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Add component data `core.type` marker for `AppRouter` and `FlatRoutes`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Add examples for `gitlab:projectAccessToken:create` scaffolder action & improve related tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Ignore `stdin` when spawning backend child process for the `start` command. Fixing an issue where backend startup would hang.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add examples for `publish:github:pull-request` scaffolder action & improve related tests
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Use default extensions boundary and suspense on the alpha declarative `createSearchResultListItem` extension factory.
+1
View File
@@ -430,6 +430,7 @@ Valentina
validator
validators
varchar
vite
VMware
Vodafone
VPCs
@@ -34,7 +34,7 @@ jobs:
ref: 'refs/pull/${{ github.event.pull_request.number }}/merge'
- name: fetch base
run: git fetch --depth 1 origin ${{ github.base_ref }}
- uses: backstage/actions/changeset-feedback@v0.6.4
- uses: backstage/actions/changeset-feedback@v0.6.5
name: Generate feedback
with:
diff-ref: 'origin/master'
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -207,7 +207,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
with:
egress-policy: audit
- uses: backstage/actions/cron@v0.6.4
- uses: backstage/actions/cron@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@497722d45fb5a35c14c721f63ed74e6ad5f629dd # v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -150,7 +150,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -15,4 +15,4 @@ jobs:
egress-policy: audit
- name: Issue sync
uses: backstage/actions/issue-sync@v0.6.4
uses: backstage/actions/issue-sync@v0.6.5
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
const prNumber = artifact.name.slice('pr_number-'.length)
core.setOutput('pr-number', prNumber);
- uses: backstage/actions/re-review@v0.6.4
- uses: backstage/actions/re-review@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
egress-policy: audit
- name: PR sync
uses: backstage/actions/pr-sync@v0.6.4
uses: backstage/actions/pr-sync@v0.6.5
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
+1 -1
View File
@@ -66,6 +66,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4
with:
sarif_file: results.sarif
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -24,7 +24,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
+1 -1
View File
@@ -58,6 +58,6 @@ jobs:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
NODE_OPTIONS: --max-old-space-size=7168
- name: Upload Snyk report
uses: github/codeql-action/upload-sarif@v2.21.8
uses: github/codeql-action/upload-sarif@v2.22.4
with:
sarif_file: snyk.sarif
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: linux-v18
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
with:
node-version: 18.x
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
- name: run Lighthouse CI
+3 -3
View File
@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2.21.8
uses: github/codeql-action/init@v2.22.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2.21.8
uses: github/codeql-action/autobuild@v2.22.4
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2.21.8
uses: github/codeql-action/analyze@v2.22.4
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # latest
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
- name: storybook yarn install
+1 -1
View File
@@ -78,7 +78,7 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/
| [Telstra](https://www.telstra.com.au) | [@kiranpatel11](https://github.com/kiranpatel11), [JasonC](https://github.com/JasonC17) | Primary usage: software catalog and templates<br/> Emerging usage : TechDocs, Explore Ecosystem, TechRadar, etc |
| [Mosaico](https://www.mosaico.com.br/) | [Wédney Yuri](https://github.com/wedneyyuri),[@tino.milton](https://github.com/miltonjacomini) | A centralized service catalog of our documentation for our service engineers. |
| [Mox Bank](https://www.mox.com/) | [Nick Laqua](https://github.com/nick-laqua-dragon) | "Single pane of glass" developer portal for providing a best-in-class developer experience to our product teams and making Mox the best tech environment in Hongkong 🥰🚀 |
| [Keyloop](https://www.keyloop.com/) | [Andre Wanlin](https://github.com/awanlin) | Future-motive Developer Portal to help our teams create technology to make everything about buying and owning a car better. 🚗 |
| [Keyloop](https://www.keyloop.com/) | [Shawn Bruce](https://github.com/sbruce-keyloop) | Future-motive Developer Portal to help our teams create technology to make everything about buying and owning a car better. 🚗 |
| [Simply Business](https://sbtech.simplybusiness.co.uk/) | [@addersuk](https://github.com/addersuk), [@LightningStairs](https://github.com/LightningStairs), [@punitcse](https://github.com/punitcse), [@moltenice](https://github.com/moltenice) | Central developer portal to access everything a developer needs such as docs, internal service catalog, and the ability to quickly create a new service from a template. Internally developed Backstage plugins allow us to customise the experience to how we work. |
| [Overwolf](https://www.overwolf.com) | [@tomwolfgang](https://github.com/tomwolfgang) | Dev portal - software catalog, tech-docs, scaffolding |
| [Hotmart](https://www.hotmart.com) | [@fabioviana-hotmart](https://github.com/fabioviana-hotmart) | The main Developers Portal to centralize docs, applications and technical metrics. |
+1 -1
View File
@@ -145,7 +145,7 @@ Scope: The Scaffolder frontend and backend plugins, and related tooling.
| ------------------------------ | ------------------------- | ----------------------------------------------------- | ------------------------------ |
| Adam Harvey | Cisco | [adamdmharvey](https://github.com/adamdmharvey) | `adamharvey_` |
| Alex Crome | | [afscrome](https://github.com/afscrome) | `afscrome` |
| Andre Wanlin | Keyloop | [awanlin](https://github.com/awanlin) | `ahhhndre` |
| Andre Wanlin | Spotify | [awanlin](https://github.com/awanlin) | `ahhhndre` |
| Andrew Thauer | Wealthsimple | [andrewthauer](https://github.com/andrewthauer) | `andrewthauer#3060` |
| Aramis Sennyey | Spotify | [sennyeya](https://github.com/sennyeya) | `Aramis#7984` |
| Brian Fletcher | Roadie.io | [punkle](https://github.com/punkle) | `Brian Fletcher#7051` |
@@ -23,7 +23,7 @@ import scaffolderPlugin from '@backstage/plugin-scaffolder-backend';
const backend = createBackend();
// Install desired features
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
// Features can also be installed using an explicit reference
backend.add(scaffolderPlugin());
@@ -24,9 +24,9 @@ import { createBackend } from '@backstage/backend-defaults'; // Omitted in the e
const backend = createBackend();
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-app-backend/alpha'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -126,8 +126,8 @@ You can now trim down the `src/index.ts` files to only include the plugins and m
```ts
const backend = createBackend();
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-app-backend/alpha'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -139,7 +139,7 @@ And `backend-b`, don't forget to clean up dependencies in `package.json` as well
```ts
const backend = createBackend();
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.start();
```
@@ -177,11 +177,10 @@ custom API, so we use a helper function to transform that particular one.
To make additions as mentioned above to the environment, you will start to get
into the weeds of how the backend system wiring works. You'll need to have a
service reference and a service factory that performs the actual creation of
your service. Please see [the services
article](../architecture/03-services.md#defining-a-service) to learn how to
create a service ref and its default factory. You can place that code directly
in the index file for now if you want, or near the actual implementation class
in question.
your service. Please see [the services article](../architecture/03-services.md)
to learn how to create a service ref and its default factory. You can place that
code directly in the index file for now if you want, or near the actual implementation
class in question.
In this example, we'll assume that your added environment field is named
`example`, and the created ref is named `exampleServiceRef`.
@@ -233,7 +232,7 @@ be used in its new form.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-app-backend/alpha'));
```
If you need to override the app package name, which otherwise defaults to `"app"`,
@@ -248,7 +247,7 @@ A basic installation of the catalog plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-start */
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -296,7 +295,7 @@ const catalogModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend/alpha'));
backend.add(
import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'),
);
@@ -390,7 +389,7 @@ A basic installation of the scaffolder plugin looks as follows.
```ts title="packages/backend/src/index.ts"
const backend = createBackend();
/* highlight-add-next-line */
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
```
If you have other customizations made to `plugins/scaffolder.ts`, such as adding
@@ -429,7 +428,7 @@ const scaffolderModuleCustomExtensions = createBackendModule({
/* highlight-add-end */
const backend = createBackend();
backend.add(import('@backstage/plugin-scaffolder-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
/* highlight-add-next-line */
backend.add(scaffolderModuleCustomExtensions());
```
@@ -22,7 +22,7 @@ Imagine you have a plugin that is responsible for storing FAQ snippets in a data
The search platform provides an interface (`DocumentCollatorFactory` from package `@backstage/plugin-search-common`) that allows you to do exactly that. It works by registering each of your entries as a "document" that later represents one search result each.
> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/stack-overflow-backend/src/search/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
> You can always look at a working example, e.g. [StackOverflowQuestionsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/search-backend-module-stack-overflow-collator/src/collators/StackOverflowQuestionsCollatorFactory.ts), if you are unsure or want to follow best practices.
#### 1. Install collator interface dependencies
+1 -1
View File
@@ -1299,7 +1299,7 @@
- d3fea4ae0a: Internal fixes to avoid implicit usage of globals
- 3280711113: Updated dependency `msw` to `^0.49.0`.
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0
- @backstage/core-components@0.12.1
+1 -1
View File
@@ -525,7 +525,7 @@
### Patch Changes
- 9516b0c355: Added support for sending virtual pageviews on `search` events in order to enable
Site Search functionality in GA. For more information consult [README](/plugins/analytics-module-ga/README.md#enabling-site-search)
Site Search functionality in GA. For more information consult [README](https://github.com/backstage/backstage/blob/master/plugins/analytics-module-ga/README.md#enabling-site-search)
- Updated dependencies
- @backstage/core-plugin-api@1.2.0-next.2
- @backstage/core-components@0.12.1-next.2
+7 -2
View File
@@ -4,12 +4,17 @@ app:
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
# waiting for https://github.com/backstage/backstage/pull/20605
# catalog.externalRoutes.viewTechDoc: techdocs.routes.docRoot
plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
extensions:
- apis.plugin.graphiql.browse.gitlab: true
# Entity page cards
- 'entity.cards.about'
# Entity page content
- 'entity.content.techdocs'
# scmAuthExtension: >-
# createScmAuthExtension({
# id: 'apis.scmAuth.addons.ghe',
-1
View File
@@ -68,7 +68,6 @@
"@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:^",
+1 -16
View File
@@ -29,11 +29,8 @@ import {
createExtension,
createApiExtension,
createExtensionOverrides,
createPageExtension,
} from '@backstage/frontend-plugin-api';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -75,13 +72,6 @@ TODO:
/* app.tsx */
const entityPageExtension = createPageExtension({
id: 'catalog:entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => <div>Just a temporary mocked entity page</div>,
});
const homePageExtension = createExtension({
id: 'myhomepage',
attachTo: { id: 'home', input: 'props' },
@@ -122,12 +112,7 @@ const app = createApp({
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [
entityPageExtension,
homePageExtension,
scmAuthExtension,
scmIntegrationApi,
],
extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
}),
],
/* Handled through config instead */
+4
View File
@@ -93,12 +93,16 @@
"@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",
"@vitejs/plugin-react": "^4.0.4",
"history": "^5.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"react-use": "^17.2.4",
"vite": "^4.4.9",
"vite-plugin-html": "^3.2.0",
"vite-plugin-node-polyfills": "^0.14.1",
"zen-observable": "^0.10.0"
},
"devDependencies": {
@@ -41,6 +41,7 @@ import {
EntityAzurePullRequestsContent,
isAzureDevOpsAvailable,
isAzurePipelinesAvailable,
EntityAzureReadmeCard,
} from '@backstage/plugin-azure-devops';
import {
isOctopusDeployAvailable,
@@ -415,6 +416,14 @@ const overviewContent = (
</EntitySwitch.Case>
</EntitySwitch>
<EntitySwitch>
<EntitySwitch.Case if={isAzureDevOpsAvailable}>
<Grid item md={6}>
<EntityAzureReadmeCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
@@ -57,7 +57,26 @@ function isPromise<T>(value: unknown | Promise<T>): value is Promise<T> {
}
function unwrapFeature(
feature: BackendFeature | (() => BackendFeature),
feature:
| BackendFeature
| (() => BackendFeature)
| { default: BackendFeature | (() => BackendFeature) },
): BackendFeature {
return typeof feature === 'function' ? feature() : feature;
if (typeof feature === 'function') {
return feature();
}
if ('$$type' in feature) {
return feature;
}
// This is a workaround where default exports get transpiled to `exports['default'] = ...`
// in CommonJS modules, which in turn results in a double `{ default: { default: ... } }` nesting
// when importing using a dynamic import.
// TODO: This is a broader issue than just this piece of code, and should move away from CommonJS.
if ('default' in feature) {
const defaultFeature = feature.default;
return typeof defaultFeature === 'function'
? defaultFeature()
: defaultFeature;
}
return feature;
}
+19 -3
View File
@@ -40,8 +40,6 @@
"@backstage/integration": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@backstage/types": "workspace:^",
"@esbuild-kit/cjs-loader": "^2.4.1",
"@esbuild-kit/esm-loader": "^2.5.5",
"@manypkg/get-packages": "^1.1.3",
"@octokit/graphql": "^5.0.0",
"@octokit/graphql-schema": "^13.7.0",
@@ -131,6 +129,7 @@
"swc-loader": "^0.2.3",
"tar": "^6.1.12",
"terser-webpack-plugin": "^5.1.3",
"tsx": "^3.14.0",
"util": "^0.12.3",
"webpack": "^5.70.0",
"webpack-dev-server": "^4.7.3",
@@ -153,6 +152,7 @@
"@backstage/theme": "workspace:^",
"@types/cross-spawn": "^6.0.2",
"@types/diff": "^5.0.0",
"@types/ejs": "^3.1.3",
"@types/express": "^4.17.6",
"@types/fs-extra": "^9.0.1",
"@types/http-proxy": "^1.17.4",
@@ -174,11 +174,27 @@
"type-fest": "^2.19.0"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.21.2"
"@microsoft/api-extractor": "^7.21.2",
"@vitejs/plugin-react": "^4.0.4",
"vite": "^4.4.9",
"vite-plugin-html": "^3.2.0",
"vite-plugin-node-polyfills": "^0.14.1"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@vitejs/plugin-react": {
"optional": true
},
"vite": {
"optional": true
},
"vite-plugin-html": {
"optional": true
},
"vite-plugin-node-polyfills": {
"optional": true
}
},
"files": [
+1 -9
View File
@@ -39,6 +39,7 @@ import { runPlain } from '../run';
import { transforms } from './transforms';
import { version } from '../../lib/version';
import yn from 'yn';
import { hasReactDomClient } from './hasReactDomClient';
const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE';
@@ -81,15 +82,6 @@ async function readBuildInfo() {
};
}
function hasReactDomClient() {
try {
require.resolve('react-dom/client');
return true;
} catch {
return false;
}
}
export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
@@ -0,0 +1,23 @@
/*
* 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 function hasReactDomClient() {
try {
require.resolve('react-dom/client');
return true;
} catch {
return false;
}
}
@@ -77,24 +77,28 @@ async function detectPackages(
return [];
}
const depPackageJson: BackstagePackageJson = require(require.resolve(
`${depName}/package.json`,
{ paths: [targetPath] },
));
if (
['frontend-plugin', 'frontend-plugin-module'].includes(
depPackageJson.backstage?.role ?? '',
)
) {
// Include alpha entry point if available. If there's no default export it will be ignored
const exp = depPackageJson.exports;
if (exp && typeof exp === 'object' && './alpha' in exp) {
return [
{ name: depName, import: depName },
{ name: depName, export: './alpha', import: `${depName}/alpha` },
];
try {
const depPackageJson: BackstagePackageJson = require(require.resolve(
`${depName}/package.json`,
{ paths: [targetPath] },
));
if (
['frontend-plugin', 'frontend-plugin-module'].includes(
depPackageJson.backstage?.role ?? '',
)
) {
// Include alpha entry point if available. If there's no default export it will be ignored
const exp = depPackageJson.exports;
if (exp && typeof exp === 'object' && './alpha' in exp) {
return [
{ name: depName, import: depName },
{ name: depName, export: './alpha', import: `${depName}/alpha` },
];
}
return [{ name: depName, import: depName }];
}
return [{ name: depName, import: depName }];
} catch {
/* ignore packages that don't make package.json available */
}
return [];
});
+106 -53
View File
@@ -34,6 +34,7 @@ import { createConfig, resolveBaseUrl } from './config';
import { createDetectedModulesEntryPoint } from './packageDetection';
import { resolveBundlingPaths } from './paths';
import { ServeOptions } from './types';
import { hasReactDomClient } from './hasReactDomClient';
export async function serveBundle(options: ServeOptions) {
const paths = resolveBundlingPaths(options);
@@ -77,7 +78,9 @@ export async function serveBundle(options: ServeOptions) {
const { name } = await fs.readJson(libPaths.resolveTarget('package.json'));
let server: WebpackDevServer | undefined = undefined;
let webpackServer: WebpackDevServer | undefined = undefined;
let viteServer: import('vite').ViteDevServer | undefined = undefined;
let latestFrontendAppConfigs: AppConfig[] = [];
const cliConfig = await loadCliConfig({
@@ -86,7 +89,9 @@ export async function serveBundle(options: ServeOptions) {
withFilteredKeys: true,
watch(appConfigs) {
latestFrontendAppConfigs = appConfigs;
server?.invalidate();
webpackServer?.invalidate();
viteServer?.restart();
},
});
latestFrontendAppConfigs = cliConfig.frontendAppConfigs;
@@ -123,7 +128,8 @@ export async function serveBundle(options: ServeOptions) {
config: fullConfig,
targetPath: paths.targetPath,
watch() {
server?.invalidate();
webpackServer?.invalidate();
viteServer?.restart();
},
});
@@ -139,64 +145,111 @@ export async function serveBundle(options: ServeOptions) {
additionalEntryPoints: detectedModulesEntryPoint,
});
const compiler = webpack(config);
server = new WebpackDevServer(
{
hot: !process.env.CI,
devMiddleware: {
publicPath: config.output?.publicPath as string,
stats: 'errors-warnings',
if (process.env.EXPERIMENTAL_VITE) {
const { default: vite } = await import('vite');
const { default: viteReact } = await import('@vitejs/plugin-react');
const { nodePolyfills: viteNodePolyfills } = await import(
'vite-plugin-node-polyfills'
);
const { createHtmlPlugin: viteHtml } = await import('vite-plugin-html');
viteServer = await vite.createServer({
define: {
global: 'window',
'process.argv': JSON.stringify(process.argv),
'process.env.APP_CONFIG': JSON.stringify(cliConfig.frontendAppConfigs),
// This allows for conditional imports of react-dom/client, since there's no way
// to check for presence of it in source code without module resolution errors.
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()),
},
static: paths.targetPublic
? {
publicPath: config.output?.publicPath as string,
directory: paths.targetPublic,
}
: undefined,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true,
// The index needs to be rewritten relative to the new public path, including subroutes.
index: `${config.output?.publicPath}index.html`,
plugins: [
viteReact(),
viteNodePolyfills(),
viteHtml({
entry: paths.targetEntry,
// todo(blam): we should look at contributing to thPe plugin here
// to support absolute paths, but works in the interim at least.
template: 'public/index.html',
inject: {
data: {
config: frontendConfig,
publicPath: config.output?.publicPath,
},
},
}),
],
server: {
host,
port,
},
https:
url.protocol === 'https:'
? {
cert: fullConfig.getString('app.https.certificate.cert'),
key: fullConfig.getString('app.https.certificate.key'),
}
: false,
host,
port,
proxy: targetPkg.proxy,
// When the dev server is behind a proxy, the host and public hostname differ
allowedHosts: [url.hostname],
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
},
} as any,
compiler as any,
);
await new Promise<void>((resolve, reject) => {
server?.startCallback((err?: Error) => {
if (err) {
reject(err);
return;
}
openBrowser(url.href);
resolve();
publicDir: paths.targetPublic,
root: paths.targetPath,
});
} else {
const compiler = webpack(config);
webpackServer = new WebpackDevServer(
{
hot: !process.env.CI,
devMiddleware: {
publicPath: config.output?.publicPath as string,
stats: 'errors-warnings',
},
static: paths.targetPublic
? {
publicPath: config.output?.publicPath as string,
directory: paths.targetPublic,
}
: undefined,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true,
// The index needs to be rewritten relative to the new public path, including subroutes.
index: `${config.output?.publicPath}index.html`,
},
https:
url.protocol === 'https:'
? {
cert: fullConfig.getString('app.https.certificate.cert'),
key: fullConfig.getString('app.https.certificate.key'),
}
: false,
host,
port,
proxy: targetPkg.proxy,
// When the dev server is behind a proxy, the host and public hostname differ
allowedHosts: [url.hostname],
client: {
webSocketURL: 'auto://0.0.0.0:0/ws',
},
},
compiler,
);
}
await viteServer?.listen();
await new Promise<void>(async (resolve, reject) => {
if (webpackServer) {
webpackServer.startCallback((err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
} else {
resolve();
}
});
openBrowser(url.href);
const waitForExit = async () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
server?.close();
webpackServer?.close();
viteServer?.close();
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
process.exit();
});
@@ -27,11 +27,14 @@ import { isAbsolute as isAbsolutePath } from 'path';
import { paths } from '../paths';
import spawn from 'cross-spawn';
const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
const supportsModuleLoaderRegister = nodeMajor >= 20 && nodeMinor >= 6;
const loaderArgs = [
'--require',
require.resolve('@esbuild-kit/cjs-loader'),
'--loader',
pathToFileURL(require.resolve('@esbuild-kit/esm-loader')).toString(), // Windows prefers a URL here
require.resolve('tsx/preflight'),
supportsModuleLoaderRegister ? '--import' : '--loader',
pathToFileURL(require.resolve('tsx')).toString(), // Windows prefers a URL here
];
export async function startBackendExperimental(options: BackendServeOptions) {
@@ -95,7 +98,7 @@ export async function startBackendExperimental(options: BackendServeOptions) {
process.execPath,
[...loaderArgs, ...optionArgs, options.entry, ...userArgs],
{
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
env: {
...process.env,
BACKSTAGE_CLI_CHANNEL: '1',
@@ -95,6 +95,14 @@ describe('ConfigSources', () => {
),
).toEqual([{ name: 'FileConfigSource', path: '/config.yaml' }]);
expect(
mergeSources(
ConfigSources.defaultForTargets({
targets: [{ type: 'path', target: 'config.yaml' }],
}),
),
).toEqual([{ name: 'FileConfigSource', path: resolvePath('config.yaml') }]);
const subFunc = async () => undefined;
expect(
mergeSources(
@@ -172,8 +180,8 @@ describe('ConfigSources', () => {
}),
),
).toEqual([
{ name: 'FileConfigSource', path: 'a.yaml' },
{ name: 'FileConfigSource', path: 'b.yaml' },
{ name: 'FileConfigSource', path: resolvePath('a.yaml') },
{ name: 'FileConfigSource', path: resolvePath('b.yaml') },
{ name: 'EnvConfigSource', env: { HOME: '/' } },
]);
});
@@ -161,7 +161,7 @@ export class ConfigSources {
}
return FileConfigSource.create({
watch: options.watch,
path: arg.target,
path: resolvePath(arg.target),
substitutionFunc: options.substitutionFunc,
});
});
@@ -16,6 +16,7 @@
import React, { useContext, ReactNode, ComponentType, useState } from 'react';
import {
attachComponentData,
ConfigApi,
configApiRef,
IdentityApi,
@@ -186,3 +187,5 @@ export function AppRouter(props: AppRouterProps) {
</RouterComponent>
);
}
attachComponentData(AppRouter, 'core.type', 'AppRouter');
@@ -16,7 +16,11 @@
import React, { ReactNode, useMemo } from 'react';
import { useRoutes } from 'react-router-dom';
import { useApp, useElementFilter } from '@backstage/core-plugin-api';
import {
attachComponentData,
useApp,
useElementFilter,
} from '@backstage/core-plugin-api';
import { isReactRouterBeta } from '../app/isReactRouterBeta';
let warned = false;
@@ -115,3 +119,5 @@ export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
return useRoutes(withNotFound);
};
attachComponentData(FlatRoutes, 'core.type', 'FlatRoutes');
+7
View File
@@ -6,11 +6,18 @@
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { default as React_2 } from 'react';
// @public (undocumented)
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[];
// @public (undocumented)
export function convertLegacyApp(
rootElement: React_2.JSX.Element,
): (ExtensionOverrides | BackstagePlugin)[];
// (No @packageDocumentation comment for this package)
```
@@ -30,6 +30,7 @@ describe('collectLegacyRoutes', () => {
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>,
);
@@ -85,6 +86,12 @@ describe('collectLegacyRoutes', () => {
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
@@ -62,7 +62,10 @@ Existing tasks:
export function collectLegacyRoutes(
flatRoutesElement: JSX.Element,
): BackstagePlugin[] {
const results = new Array<BackstagePlugin>();
const createdPluginIds = new Map<
LegacyBackstagePlugin,
Extension<unknown>[]
>();
React.Children.forEach(
flatRoutesElement.props.children,
@@ -93,13 +96,18 @@ export function collectLegacyRoutes(
);
const pluginId = plugin.getId();
const path: string = route.props.path;
const detectedExtensions = new Array<Extension<unknown>>();
const detectedExtensions =
createdPluginIds.get(plugin) ?? new Array<Extension<unknown>>();
createdPluginIds.set(plugin, detectedExtensions);
const path: string = route.props.path;
detectedExtensions.push(
createPageExtension({
id: `plugin.${pluginId}.page`,
id: `plugin.${pluginId}.page${
detectedExtensions.length ? detectedExtensions.length + 1 : ''
}`,
defaultPath: path[0] === '/' ? path.slice(1) : path,
routeRef: routeRef ? convertLegacyRouteRef(routeRef) : undefined,
@@ -115,23 +123,20 @@ export function collectLegacyRoutes(
),
}),
);
},
);
detectedExtensions.push(
return Array.from(createdPluginIds).map(([plugin, extensions]) =>
createPlugin({
id: plugin.getId(),
extensions: [
...extensions,
...Array.from(plugin.getApis()).map(factory =>
createApiExtension({
factory,
}),
),
);
results.push(
createPlugin({
id: plugin.getId(),
extensions: detectedExtensions,
}),
);
},
],
}),
);
return results;
}
@@ -0,0 +1,129 @@
/*
* 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 { PuppetDbPage } from '@backstage/plugin-puppetdb';
import { StackstormPage } from '@backstage/plugin-stackstorm';
import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card';
import React, { ReactNode } from 'react';
import { Route } from 'react-router-dom';
import { convertLegacyApp } from './convertLegacyApp';
const Root = ({ children }: { children: ReactNode }) => <>{children}</>;
describe('convertLegacyApp', () => {
it('should find and extract root and routes', () => {
const collected = convertLegacyApp(
<>
<div />
<span />
<AppRouter>
<div />
<Root>
<FlatRoutes>
<Route path="/score-board" element={<ScoreBoardPage />} />
<Route path="/stackstorm" element={<StackstormPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
<Route path="/puppetdb" element={<PuppetDbPage />} />
</FlatRoutes>
</Root>
</AppRouter>
</>,
);
expect(
collected.map((p: any /* TODO */) => ({
id: p.id,
extensions: p.extensions.map((e: any) => ({
id: e.id,
attachTo: e.attachTo,
disabled: e.disabled,
defaultConfig: e.configSchema?.parse({}),
})),
})),
).toEqual([
{
id: 'score-card',
extensions: [
{
id: 'plugin.score-card.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'score-board' },
},
{
id: 'apis.plugin.scoringdata.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'stackstorm',
extensions: [
{
id: 'plugin.stackstorm.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'stackstorm' },
},
{
id: 'apis.plugin.stackstorm.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: 'puppetDb',
extensions: [
{
id: 'plugin.puppetDb.page',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'plugin.puppetDb.page2',
attachTo: { id: 'core.routes', input: 'routes' },
disabled: false,
defaultConfig: { path: 'puppetdb' },
},
{
id: 'apis.plugin.puppetdb.service',
attachTo: { id: 'core', input: 'apis' },
disabled: false,
},
],
},
{
id: undefined,
extensions: [
{
id: 'core.layout',
attachTo: { id: 'core', input: 'root' },
disabled: false,
},
{
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
disabled: true,
},
],
},
]);
});
});
@@ -0,0 +1,139 @@
/*
* 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, {
Children,
Fragment,
ReactElement,
ReactNode,
isValidElement,
} from 'react';
import {
BackstagePlugin,
ExtensionOverrides,
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { getComponentData } from '@backstage/core-plugin-api';
import { collectLegacyRoutes } from './collectLegacyRoutes';
function selectChildren(
rootNode: ReactNode,
selector?: (element: ReactElement<{ children?: ReactNode }>) => boolean,
strictError?: string,
): Array<ReactElement<{ children?: ReactNode }>> {
return Children.toArray(rootNode).flatMap(node => {
if (!isValidElement<{ children?: ReactNode }>(node)) {
return [];
}
if (node.type === Fragment) {
return selectChildren(node.props.children, selector, strictError);
}
if (selector === undefined || selector(node)) {
return [node];
}
if (strictError) {
throw new Error(strictError);
}
return selectChildren(node.props.children, selector, strictError);
});
}
/** @public */
export function convertLegacyApp(
rootElement: React.JSX.Element,
): (ExtensionOverrides | BackstagePlugin)[] {
const appRouterEls = selectChildren(
rootElement,
el => getComponentData(el, 'core.type') === 'AppRouter',
);
if (appRouterEls.length !== 1) {
throw new Error(
"Failed to convert legacy app, AppRouter element could not been found. Make sure it's at the top level of the App element tree",
);
}
const rootEls = selectChildren(
appRouterEls[0].props.children,
el =>
Boolean(el.props.children) &&
selectChildren(
el.props.children,
innerEl => getComponentData(innerEl, 'core.type') === 'FlatRoutes',
).length === 1,
);
if (rootEls.length !== 1) {
throw new Error(
"Failed to convert legacy app, Root element containing FlatRoutes could not been found. Make sure it's within the AppRouter element of the App element tree",
);
}
const [rootEl] = rootEls;
const routesEls = selectChildren(
rootEls[0].props.children,
el => getComponentData(el, 'core.type') === 'FlatRoutes',
);
if (routesEls.length !== 1) {
throw new Error(
'Unexpectedly failed to find FlatRoutes in app element tree',
);
}
const [routesEl] = routesEls;
const CoreLayoutOverride = createExtension({
id: 'core.layout',
attachTo: { id: 'core', input: 'root' },
inputs: {
content: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
{ singleton: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
// Clone the root element, this replaces the FlatRoutes declared in the app with out content input
bind({
element: React.cloneElement(rootEl, undefined, inputs.content.element),
});
},
});
const CoreNavOverride = createExtension({
id: 'core.nav',
attachTo: { id: 'core.layout', input: 'nav' },
output: {},
factory() {},
disabled: true,
});
const collectedRoutes = collectLegacyRoutes(routesEl);
return [
...collectedRoutes,
createExtensionOverrides({
extensions: [CoreLayoutOverride, CoreNavOverride],
}),
];
}
+1
View File
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { collectLegacyRoutes } from './collectLegacyRoutes';
export { convertLegacyApp } from './convertLegacyApp';
@@ -27,8 +27,8 @@ import { SubRoute } from './types';
export function useSelectedSubRoute(subRoutes: SubRoute[]): {
index: number;
route: SubRoute;
element: JSX.Element;
route?: SubRoute;
element?: JSX.Element;
} {
const params = useParams();
@@ -44,7 +44,7 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): {
b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')),
);
const element = useRoutes(sortedRoutes) ?? subRoutes[0].children;
const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children;
// TODO(Rugvip): Once we only support v6 stable we can always prefix
// This avoids having a double / prefix for react-router v6 beta, which in turn breaks
@@ -98,7 +98,7 @@ export function RoutedTabs(props: { routes: SubRoute[] }) {
onChange={onTabChange}
/>
<Content>
<Helmet title={route.title} />
<Helmet title={route?.title} />
{element}
</Content>
</>
+1 -1
View File
@@ -129,7 +129,7 @@ export default async (opts: OptionValues): Promise<void> => {
);
Task.log();
Task.section('All set! Now you might want to');
if (!opts.skipInstall) {
if (opts.skipInstall) {
Task.log(
` Install the dependencies: ${chalk.cyan(
`cd ${opts.path ?? answers.name} && yarn install`,
+14 -8
View File
@@ -49,11 +49,13 @@ import { createRoutesFromChildren, Route } from 'react-router-dom';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
import 'react-dom';
let ReactDOM: typeof import('react-dom') | typeof import('react-dom/client');
let ReactDOMPromise: Promise<
typeof import('react-dom') | typeof import('react-dom/client')
>;
if (process.env.HAS_REACT_DOM_CLIENT) {
ReactDOM = require('react-dom/client');
ReactDOMPromise = import('react-dom/client');
} else {
ReactDOM = require('react-dom');
ReactDOMPromise = import('react-dom');
}
export function isReactRouterBeta(): boolean {
@@ -242,11 +244,15 @@ export class DevAppBuilder {
window.location.pathname = this.defaultPage;
}
if ('createRoot' in ReactDOM) {
ReactDOM.createRoot(document.getElementById('root')!).render(<DevApp />);
} else {
ReactDOM.render(<DevApp />, document.getElementById('root'));
}
ReactDOMPromise.then(ReactDOM => {
if ('createRoot' in ReactDOM) {
ReactDOM.createRoot(document.getElementById('root')!).render(
<DevApp />,
);
} else {
ReactDOM.render(<DevApp />, document.getElementById('root'));
}
});
}
}
+5 -5
View File
@@ -34,17 +34,17 @@ export interface Config {
/**
* @deepVisibility frontend
*/
extensions?:
extensions?: Array<
| string
| {
[extensionId: string]:
| boolean
| string
| {
at?: string;
extension?: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
config?: unknown;
};
};
}
>;
};
}
@@ -22,7 +22,7 @@ import {
export const Core = createExtension({
id: 'core',
attachTo: { id: 'root', input: 'default' },
attachTo: { id: 'root', input: 'default' }, // ignored
inputs: {
apis: createExtensionInput({
api: coreExtensionData.apiFactory,
@@ -0,0 +1,123 @@
/*
* 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 {
createExtension,
createExtensionOverrides,
createPlugin,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { createAppGraph } from './createAppGraph';
const extBase = {
id: 'test',
attachTo: { id: 'core', input: 'root' },
output: {},
factory() {},
};
describe('createAppGraph', () => {
it('throws an error when a core extension is parametrized', () => {
const config = new MockConfigApi({
app: {
extensions: [
{
core: {},
},
],
},
});
const features = [
createPlugin({
id: 'plugin',
extensions: [],
}),
];
expect(() =>
createAppGraph({ features, config, builtinExtensions: [] }),
).toThrow("Configuration of the 'core' extension is forbidden");
});
it('throws an error when a core extension is overridden', () => {
const config = new MockConfigApi({});
const features = [
createPlugin({
id: 'plugin',
extensions: [
createExtension({
id: 'core',
attachTo: { id: 'core.routes', input: 'route' },
inputs: {},
output: {},
factory() {},
}),
],
}),
];
expect(() =>
createAppGraph({ features, config, builtinExtensions: [] }),
).toThrow(
"It is forbidden to override the following extension(s): 'core', which is done by the following plugin(s): 'plugin'",
);
});
it('throws an error when duplicated extensions are detected', () => {
const config = new MockConfigApi({});
const ExtensionA = createExtension({ ...extBase, id: 'A' });
const ExtensionB = createExtension({ ...extBase, id: 'B' });
const PluginA = createPlugin({
id: 'A',
extensions: [ExtensionA, ExtensionA],
});
const PluginB = createPlugin({
id: 'B',
extensions: [ExtensionA, ExtensionB, ExtensionB],
});
const features = [PluginA, PluginB];
expect(() =>
createAppGraph({ features, config, builtinExtensions: [] }),
).toThrow(
"The following extensions are duplicated: The extension 'A' was provided 2 time(s) by the plugin 'A' and 1 time(s) by the plugin 'B', The extension 'B' was provided 2 time(s) by the plugin 'B'",
);
});
it('throws an error when duplicated extension overrides are detected', () => {
expect(() =>
createAppGraph({
features: [
createExtensionOverrides({
extensions: [
createExtension({ ...extBase, id: 'a' }),
createExtension({ ...extBase, id: 'a' }),
createExtension({ ...extBase, id: 'b' }),
],
}),
createExtensionOverrides({
extensions: [createExtension({ ...extBase, id: 'b' })],
}),
],
config: new MockConfigApi({}),
builtinExtensions: [],
}),
).toThrow('The following extensions had duplicate overrides: a, b');
});
});
@@ -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 {
BackstagePlugin,
Extension,
ExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { readAppExtensionsConfig } from './readAppExtensionsConfig';
import { resolveAppGraph } from './resolveAppGraph';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
import { AppGraph } from './types';
import { Config } from '@backstage/config';
import { instantiateAppNodeTree } from './instantiateAppNodeTree';
/** @internal */
export interface CreateAppGraphOptions {
features: (BackstagePlugin | ExtensionOverrides)[];
builtinExtensions: Extension<unknown>[];
config: Config;
}
/** @internal */
export function createAppGraph(options: CreateAppGraphOptions): AppGraph {
const appGraph = resolveAppGraph(
'core',
resolveAppNodeSpecs({
features: options.features,
builtinExtensions: options.builtinExtensions,
parameters: readAppExtensionsConfig(options.config),
forbidden: new Set(['core']),
}),
);
instantiateAppNodeTree(appGraph.root);
return appGraph;
}
@@ -0,0 +1,23 @@
/*
* 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 type {
AppNode,
AppNodeEdges,
AppNodeInstance,
AppNodeSpec,
} from './types';
export { createAppGraph } from './createAppGraph';
@@ -0,0 +1,554 @@
/*
* 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,
createExtension,
createExtensionDataRef,
createExtensionInput,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import {
createAppNodeInstance,
instantiateAppNodeTree,
} from './instantiateAppNodeTree';
import { AppNodeInstance, AppNodeSpec } from './types';
import { resolveAppGraph } from './resolveAppGraph';
const testDataRef = createExtensionDataRef<string>('test');
const otherDataRef = createExtensionDataRef<number>('other');
const inputMirrorDataRef = createExtensionDataRef<unknown>('mirror');
const simpleExtension = createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ bind, config }) {
bind({ test: config.output, other: config.other });
},
});
function makeSpec<TConfig>(
extension: Extension<TConfig>,
config?: TConfig,
): AppNodeSpec {
return {
id: extension.id,
attachTo: extension.attachTo,
disabled: extension.disabled,
extension,
config,
source: undefined,
};
}
function makeInstanceWithId<TConfig>(
extension: Extension<TConfig>,
config?: TConfig,
): { id: string; instance: AppNodeInstance } {
return {
id: extension.id,
instance: createAppNodeInstance({
spec: makeSpec(extension, config),
attachments: new Map(),
}),
};
}
describe('instantiateAppNodeTree', () => {
it('should instantiate a single node', () => {
const graph = resolveAppGraph('root-node', [
{ ...makeSpec(simpleExtension), id: 'root-node' },
]);
expect(graph.root.instance).not.toBeDefined();
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).toBeDefined();
expect(graph.root.instance?.getData(testDataRef)).toBe('test');
// Multiple calls should have no effect
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).toBeDefined();
});
it('should not instantiate disabled nodes', () => {
const graph = resolveAppGraph('root-node', [
{ ...makeSpec(simpleExtension), id: 'root-node', disabled: true },
]);
expect(graph.root.instance).not.toBeDefined();
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).not.toBeDefined();
});
it('should instantiate a node with attachments', () => {
const graph = resolveAppGraph('root-node', [
{
...makeSpec(
createExtension({
id: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
},
}),
),
},
{
...makeSpec(simpleExtension),
id: 'child-node',
attachTo: { id: 'root-node', input: 'test' },
},
]);
const childNode = graph.nodes.get('child-node');
expect(childNode).toBeDefined();
expect(graph.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
test: [{ test: 'test' }],
});
// Multiple calls should have no effect
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).toBeDefined();
expect(childNode?.instance).toBeDefined();
});
it('should not instantiate disabled attachments', () => {
const graph = resolveAppGraph('root-node', [
{
...makeSpec(
createExtension({
id: 'root-node',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
test: createExtensionInput({ test: testDataRef }),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
},
}),
),
},
{
...makeSpec(simpleExtension),
id: 'child-node',
attachTo: { id: 'root-node', input: 'test' },
disabled: true,
},
]);
const childNode = graph.nodes.get('child-node');
expect(childNode).toBeDefined();
expect(graph.root.instance).not.toBeDefined();
expect(childNode?.instance).not.toBeDefined();
instantiateAppNodeTree(graph.root);
expect(graph.root.instance).toBeDefined();
expect(childNode?.instance).not.toBeDefined();
expect(graph.root.instance?.getData(inputMirrorDataRef)).toEqual({
test: [],
});
});
});
describe('createAppNodeInstance', () => {
it('should create a simple extension instance', () => {
const attachments = new Map();
const instance = createAppNodeInstance({
spec: makeSpec(simpleExtension),
attachments,
});
expect(Array.from(instance.getDataRefs())).toEqual([
testDataRef,
otherDataRef.optional(),
]);
expect(instance.getData(testDataRef)).toEqual('test');
});
it('should create an extension with different kind of inputs', () => {
const attachments = new Map([
[
'optionalSingletonPresent',
[
makeInstanceWithId(simpleExtension, {
output: 'optionalSingletonPresent',
}),
],
],
[
'singleton',
[
makeInstanceWithId(simpleExtension, {
output: 'singleton',
other: 2,
}),
],
],
[
'many',
[
makeInstanceWithId(simpleExtension, { output: 'many1' }),
makeInstanceWithId(simpleExtension, { output: 'many2', other: 3 }),
],
],
]);
const instance = createAppNodeInstance({
attachments,
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
optionalSingletonPresent: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createExtensionInput({
test: testDataRef,
other: otherDataRef.optional(),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
},
}),
),
});
expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]);
expect(instance.getData(inputMirrorDataRef)).toEqual({
optionalSingletonPresent: { test: 'optionalSingletonPresent' },
singleton: { test: 'singleton', other: 2 },
many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
});
});
it('should refuse to create an extension with invalid config', () => {
expect(() =>
createAppNodeInstance({
spec: {
...makeSpec(simpleExtension),
config: { other: 'not-a-number' },
},
attachments: new Map(),
}),
).toThrow(
"Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
);
});
it('should forward extension factory errors', () => {
expect(() =>
createAppNodeInstance({
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
);
});
it('should refuse to create an instance with duplicate output', () => {
expect(() =>
createAppNodeInstance({
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({ bind }) {
bind({ test1: 'test', test2: 'test2' });
},
}),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
);
});
it('should refuse to create an instance with disconnected output data', () => {
expect(() =>
createAppNodeInstance({
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
test: testDataRef,
},
factory({ bind }) {
bind({ nonexistent: 'test' } as any);
},
}),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
);
});
it('should refuse to create an instance with missing required input', () => {
expect(() =>
createAppNodeInstance({
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
),
attachments: new Map(),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
);
});
it('should refuse to create an instance with undeclared inputs', () => {
expect(() =>
createAppNodeInstance({
attachments: new Map([
[
'declared',
[
makeInstanceWithId(simpleExtension, {
output: 'many1',
}),
],
],
[
'undeclared',
[
makeInstanceWithId(simpleExtension, {
output: 'many1',
}),
],
],
]),
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
declared: createExtensionInput({
test: testDataRef,
}),
},
output: {},
factory() {},
}),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'",
);
});
it('should refuse to create an instance with multiple undeclared inputs', () => {
expect(() =>
createAppNodeInstance({
attachments: new Map([
[
'undeclared1',
[makeInstanceWithId(simpleExtension, { output: 'many1' })],
],
[
'undeclared2',
[
makeInstanceWithId(simpleExtension, { output: 'many1' }),
makeInstanceWithId(simpleExtension, { output: 'many1' }),
],
],
]),
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
output: {},
factory() {},
}),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'",
);
});
it('should refuse to create an instance with multiple inputs for required singleton', () => {
expect(() =>
createAppNodeInstance({
attachments: new Map([
[
'singleton',
[
makeInstanceWithId(simpleExtension, { output: 'many1' }),
makeInstanceWithId(simpleExtension, { output: 'many2' }),
],
],
]),
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
);
});
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
expect(() =>
createAppNodeInstance({
attachments: new Map([
[
'singleton',
[
makeInstanceWithId(simpleExtension, { output: 'many1' }),
makeInstanceWithId(simpleExtension, { output: 'many2' }),
],
],
]),
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory() {},
}),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
);
});
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
expect(() =>
createAppNodeInstance({
attachments: new Map([
['singleton', [makeInstanceWithId(simpleExtension, undefined)]],
]),
spec: makeSpec(
createExtension({
id: 'core.test',
attachTo: { id: 'ignored', input: 'ignored' },
inputs: {
singleton: createExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
);
});
});
@@ -17,36 +17,22 @@
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
BackstagePlugin,
Extension,
ExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance, AppNodeSpec } from './types';
/** @internal */
export interface ExtensionInstance {
readonly $$type: '@backstage/ExtensionInstance';
readonly id: string;
/**
* Get concrete value for the given extension data reference. Returns undefined if no value is available.
*/
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
/**
* Maps input names to the actual instances given to them.
*/
readonly attachments: Map<string, ExtensionInstance[]>;
readonly source?: BackstagePlugin;
}
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
function resolveInputData(
dataMap: AnyExtensionDataMap,
attachment: ExtensionInstance,
attachment: { id: string; instance: AppNodeInstance },
inputName: string,
) {
return mapValues(dataMap, ref => {
const value = attachment.getData(ref);
const value = attachment.instance.getData(ref);
if (value === undefined && !ref.config.optional) {
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
@@ -58,11 +44,12 @@ function resolveInputData(
function resolveInputs(
inputMap: AnyExtensionInputMap,
attachments: Map<string, ExtensionInstance[]>,
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>,
) {
const undeclaredAttachments = Array.from(attachments.entries()).filter(
([inputName]) => inputMap[inputName] === undefined,
);
// TODO: Make this a warning rather than an error
if (undeclaredAttachments.length > 0) {
throw new Error(
`received undeclared input${
@@ -79,113 +66,49 @@ function resolveInputs(
}
return mapValues(inputMap, (input, inputName) => {
const attachedInstances = attachments.get(inputName) ?? [];
const attachedNodes = attachments.get(inputName) ?? [];
if (input.config.singleton) {
if (attachedInstances.length > 1) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.id);
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedInstances
.map(e => e.id)
.join("', '")}'`,
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
"', '",
)}'`,
);
} else if (attachedInstances.length === 0) {
} else if (attachedNodes.length === 0) {
if (input.config.optional) {
return undefined;
}
throw Error(`input '${inputName}' is required but was not received`);
}
return resolveInputData(
input.extensionData,
attachedInstances[0],
inputName,
);
return resolveInputData(input.extensionData, attachedNodes[0], inputName);
}
return attachedInstances.map(attachment =>
return attachedNodes.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
}
function indent(str: string) {
return str.replace(/^/gm, ' ');
}
class ExtensionInstanceImpl implements ExtensionInstance {
readonly $$type = '@backstage/ExtensionInstance';
readonly id: string;
readonly #extensionData: Map<string, unknown>;
readonly attachments: Map<string, ExtensionInstance[]>;
readonly source?: BackstagePlugin;
constructor(
id: string,
extensionData: Map<string, unknown>,
attachments: Map<string, ExtensionInstance[]>,
source: BackstagePlugin | undefined,
) {
this.id = id;
this.#extensionData = extensionData;
this.attachments = attachments;
this.source = source;
}
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
return this.#extensionData.get(ref.id) as T | undefined;
}
toJSON() {
return {
id: this.id,
output:
this.#extensionData.size > 0
? [...this.#extensionData.keys()]
: undefined,
attachments:
this.attachments.size > 0
? Object.fromEntries(this.attachments)
: undefined,
};
}
toString() {
const out =
this.#extensionData.size > 0
? ` out=[${[...this.#extensionData.keys()].join(', ')}]`
: '';
if (this.attachments.size === 0) {
return `<${this.id}${out} />`;
}
return [
`<${this.id}${out}>`,
...[...this.attachments.entries()].map(([k, v]) =>
indent([`${k} [`, ...v.map(e => indent(e.toString())), `]`].join('\n')),
),
`</${this.id}>`,
].join('\n');
}
}
/** @internal */
export function createExtensionInstance(options: {
extension: Extension<unknown>;
config: unknown;
source?: BackstagePlugin;
attachments: Map<string, ExtensionInstance[]>;
}): ExtensionInstance {
const { extension, config, source, attachments } = options;
export function createAppNodeInstance(options: {
spec: AppNodeSpec;
attachments: ReadonlyMap<string, { id: string; instance: AppNodeInstance }[]>;
}): AppNodeInstance {
const { spec, attachments } = options;
const { id, extension, config, source } = spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
let parsedConfig: unknown;
try {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
`Invalid configuration for extension '${extension.id}'; caused by ${e}`,
`Invalid configuration for extension '${id}'; caused by ${e}`,
);
}
@@ -205,22 +128,65 @@ export function createExtensionInstance(options: {
);
}
extensionData.set(ref.id, output);
extensionDataRefs.add(ref);
}
},
inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
`Failed to instantiate extension '${extension.id}'${
`Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
);
}
return new ExtensionInstanceImpl(
options.extension.id,
extensionData,
attachments,
source,
);
return {
getDataRefs() {
return extensionDataRefs.values();
},
getData<T>(ref: ExtensionDataRef<T>): T | undefined {
return extensionData.get(ref.id) as T | undefined;
},
};
}
/**
* Starting at the provided node, instantiate all reachable nodes in the graph that have not been disabled.
* @internal
*/
export function instantiateAppNodeTree(rootNode: AppNode): void {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
}
if (node.spec.disabled) {
return undefined;
}
const instantiatedAttachments = new Map<
string,
{ id: string; instance: AppNodeInstance }[]
>();
for (const [input, children] of node.edges.attachments) {
const instantiatedChildren = children.flatMap(child => {
const childInstance = createInstance(child);
if (!childInstance) {
return [];
}
return [{ id: child.spec.id, instance: childInstance }];
});
instantiatedAttachments.set(input, instantiatedChildren);
}
(node as Mutable<AppNode>).instance = createAppNodeInstance({
spec: node.spec,
attachments: instantiatedAttachments,
});
return node.instance;
}
createInstance(rootNode);
}

Some files were not shown because too many files have changed in this diff Show More