diff --git a/.changeset/cuddly-donuts-whisper.md b/.changeset/cuddly-donuts-whisper.md
new file mode 100644
index 0000000000..fd1b665b99
--- /dev/null
+++ b/.changeset/cuddly-donuts-whisper.md
@@ -0,0 +1,54 @@
+---
+'@backstage/plugin-explore': patch
+---
+
+Refactors the explore plugin to be more customizable. This includes the following non-breaking changes:
+
+- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage`
+- Refactor `ExplorePage` to use a new `ExploreLayout` component
+- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components
+- Allows `title` props to be customized
+
+Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
+
+```tsx
+import {
+ DomainExplorerContent,
+ ExploreLayout,
+} from '@backstage/plugin-explore';
+import React from 'react';
+import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
+
+export const ExplorePage = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const explorePage = ;
+```
+
+Now register the new explore page in `packages/app/src/App.tsx`.
+
+```diff
++ import { explorePage } from './components/explore/ExplorePage';
+
+const routes = (
+
+- } />
++ }>
++ {explorePage}
++
+
+);
+```
diff --git a/.changeset/few-windows-whisper.md b/.changeset/few-windows-whisper.md
new file mode 100644
index 0000000000..565e8798a1
--- /dev/null
+++ b/.changeset/few-windows-whisper.md
@@ -0,0 +1,8 @@
+---
+'@backstage/core-app-api': patch
+'@backstage/core-plugin-api': patch
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-scaffolder': patch
+---
+
+Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API
diff --git a/.changeset/odd-humans-exercise.md b/.changeset/odd-humans-exercise.md
new file mode 100644
index 0000000000..136ec86b69
--- /dev/null
+++ b/.changeset/odd-humans-exercise.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Make the `create-github-app` command disable webhooks by default.
diff --git a/.changeset/popular-rice-wonder.md b/.changeset/popular-rice-wonder.md
new file mode 100644
index 0000000000..f815ad949a
--- /dev/null
+++ b/.changeset/popular-rice-wonder.md
@@ -0,0 +1,8 @@
+---
+'@backstage/plugin-catalog': patch
+'@backstage/plugin-catalog-backend': patch
+'@backstage/plugin-scaffolder': patch
+'@backstage/plugin-scaffolder-backend': patch
+---
+
+Moved installation instructions from the main [backstage.io](https://backstage.io) documentation to the package README file. These instructions are not generally needed, since the plugin comes installed by default with `npx @backstage/create-app`.
diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md
new file mode 100644
index 0000000000..fa2839aad2
--- /dev/null
+++ b/.changeset/seven-adults-act.md
@@ -0,0 +1,25 @@
+---
+'@backstage/plugin-auth-backend': patch
+---
+
+Adds support for custom sign-in resolvers and profile transformations for the
+Google auth provider.
+
+Adds an `ent` claim in Backstage tokens, with a list of
+[entity references](https://backstage.io/docs/features/software-catalog/references)
+related to your signed-in user's identities and groups across multiple systems.
+
+Adds an optional `providerFactories` argument to the `createRouter` exported by
+the `auth-backend` plugin.
+
+Updates `BackstageIdentity` so that
+
+- `idToken` is deprecated in favor of `token`
+- An optional `entity` field is added which represents the entity that the user is represented by within Backstage.
+
+More information:
+
+- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver)
+ explains the concepts and shows how to implement your own.
+- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089)
+ RFC contains details about how this affects ownership in the catalog
diff --git a/.changeset/techdocs-cool-rivers-suffer.md b/.changeset/techdocs-cool-rivers-suffer.md
new file mode 100644
index 0000000000..2386d27525
--- /dev/null
+++ b/.changeset/techdocs-cool-rivers-suffer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': patch
+---
+
+Refactor the implicit logic from `` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend.
diff --git a/.changeset/techdocs-poor-forks-repeat.md b/.changeset/techdocs-poor-forks-repeat.md
new file mode 100644
index 0000000000..89c4e0dc5a
--- /dev/null
+++ b/.changeset/techdocs-poor-forks-repeat.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built. This enables the caller to know whether a refresh of the docs page will return updated content (-> `201 Created`) or not (-> `304 Not Modified`).
diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md
new file mode 100644
index 0000000000..814ff63729
--- /dev/null
+++ b/docs/auth/identity-resolver.md
@@ -0,0 +1,150 @@
+---
+id: identity-resolver
+title: Identity resolver
+description: Identity resolvers of Backstage users after they sign-in
+---
+
+This guide explains how the identity of a Backstage user is stored inside their
+Backstage Identity Token and how you can customize the Sign-In resolvers to
+include identity and group membership information of the user from other
+external systems. This ultimately helps with determining the ownership of a
+Backstage entity by a user. The ideas here were originally proposed in the RFC
+[#4089](https://github.com/backstage/backstage/issues/4089).
+
+When a user signs in to Backstage, inside the `claims` field of their Backstage
+Token (which are standard JWT tokens) a special `ent` claim is set. `ent`
+contains a list of
+[entity references](../features/software-catalog/references.md), each of which
+denotes an identity or a membership that is relevant to the user. There is no
+guarantee that these correspond to actual existing catalog entities.
+
+Let's take an example sign-in resolver for the Google auth provider and explore
+how the `ent` field inside `claims` can be set.
+
+Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom
+sign-in resolvers and set them for any of the Authentication providers inside
+`providerFactories` of the `createRouter` imported from the
+`@backstage/plugin-auth-backend` plugin.
+
+```ts
+export default async function createPlugin({
+ ...
+}: PluginEnvironment): Promise {
+ return await createRouter({
+ ...
+ providerFactories: {
+ google: createGoogleProvider({
+ signIn: {
+ resolver: async ({ profile: { email } }, ctx) => {
+ // Call a custom validator function that checks that the email is
+ // valid and on our own company's domain, and throws an Error if it
+ // isn't
+ validateEmail(email);
+
+ // List of entity references that denote the identity and
+ // membership of the user
+ const ent = [];
+
+ // Let's use the username in the email ID as the user's default
+ // unique identifier inside Backstage.
+ const [id] = email.split('@');
+ ent.push(`User:default/${id}`)
+
+ // Let's call the internal LDAP provider to get a list of groups
+ // that the user belongs to, and add those to the list as well
+ const ldapGroups = await getLdapGroups(email);
+ ldapGroups.forEach(group => ent.push(`Group:default/${group}`))
+
+ // Issue the token containing the entity claims
+ const token = await ctx.tokenIssuer.issueToken({
+ claims: { sub: id, ent },
+ });
+ return { id, token };
+ },
+ },
+ }),
+ },
+ });
+}
+```
+
+As you can see, the generated Backstage Token now contains all the claims about
+the identity and membership of the user. Once the sign-in process is complete,
+and we need to find out if a user owns an Entity in the Software Catalog, these
+`ent` claims can be used to determine the ownership.
+
+According to the RFC, the definition of the ownership of an entity E, for a user
+U, is as follows:
+
+- Get all the `ownedBy` relations of E, and call them O
+- Get all the claims of the user U and call them C
+- If any C matches any O, return `true`
+- Get all Group entities that U is a member of, using the regular
+ `memberOf`/`hasMember` relation mechanism, and call them G
+- If any G matches any O, return `true`
+- Otherwise, return `false`
+
+## Default sign-in resolvers
+
+Of course you don't have to customize the sign-in resolver if you don't need to.
+The Auth backend plugin comes with a set of default sign-in resolvers which you
+can use. For example - the Google provider has a default email-based sign-in
+resolver, which will search the catalog for a single user entity that has a
+matching `google.com/email` annotation.
+
+It can be enabled like this
+
+```tsx
+// File: packages/backend/src/plugins/auth.ts
+import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend';
+
+export default async function createPlugin({
+ ...
+}: PluginEnvironment): Promise {
+ return await createRouter({
+ ...
+ providerFactories: {
+ google: createGoogleProvider({
+ signIn: {
+ resolver: googleEmailSignInResolver
+ }
+...
+```
+
+## AuthHandler
+
+Similar to a custom sign-in resolver, you can also write a custom auth handler
+function which is used to verify and convert the auth response into the profile
+that will be presented to the user. This is where you can customize things like
+display name and profile picture.
+
+This is also the place where you can do authorization and validation of the user
+and throw errors if the user should not be allowed access in Backstage.
+
+```tsx
+// File: packages/backend/src/plugins/auth.ts
+export default async function createPlugin({
+ ...
+}: PluginEnvironment): Promise {
+ return await createRouter({
+ ...
+ providerFactories: {
+ google: createGoogleProvider({
+ authHandler: async ({
+ fullProfile // Type: passport.Profile,
+ idToken // Type: (Optional) string,
+ }) => {
+ // Custom validation code goes here
+ return {
+ profile: {
+ email,
+ picture,
+ displayName,
+ }
+ };
+ }
+ })
+ }
+ })
+}
+```
diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md
index 70541b85db..2189b7d799 100644
--- a/docs/features/software-catalog/index.md
+++ b/docs/features/software-catalog/index.md
@@ -34,9 +34,8 @@ More specifically, the Service Catalog enables two main use-cases:
## Getting Started
The Software Catalog is available to browse at `/catalog`. If you've followed
-[Installing in your Backstage App](./installation.md) in your separate App or
-[Getting Started with Backstage](../../getting-started) for this repo, you
-should be able to browse the catalog at `http://localhost:3000`.
+[Getting Started with Backstage](../../getting-started), you should be able to
+browse the catalog at `http://localhost:3000`.

diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md
deleted file mode 100644
index 0b622fb093..0000000000
--- a/docs/features/software-catalog/installation.md
+++ /dev/null
@@ -1,177 +0,0 @@
----
-id: installation
-title: Installing in your Backstage App
-description: Documentation on How to install Backstage Plugin
----
-
-The catalog plugin comes in two packages, `@backstage/plugin-catalog` and
-`@backstage/plugin-catalog-backend`. Each has their own installation steps,
-outlined below.
-
-## Installing @backstage/plugin-catalog
-
-> **Note that if you used `npx @backstage/create-app`, the plugin is already
-> installed and you can skip to
-> [adding entries to the catalog](#adding-entries-to-the-catalog)**
-
-The catalog frontend plugin should be installed in your `app` package, which is
-created as a part of `@backstage/create-app`. To install the package, run:
-
-```bash
-# From your Backstage root directory
-cd packages/app
-yarn add @backstage/plugin-catalog
-```
-
-### Adding the Plugin to your `packages/app`
-
-Add the two pages that the catalog plugin provides to your app. You can choose
-any name for these routes, but we recommend the following:
-
-```tsx
-// packages/app/src/App.tsx
-import {
- catalogPlugin,
- CatalogIndexPage,
- CatalogEntityPage,
-} from '@backstage/plugin-catalog';
-
-// Add to the top-level routes, directly within
-} />
-}>
- {/*
- This is the root of the custom entity pages for your app, refer to the example app
- in the main repo or the output of @backstage/create-app for an example
- */}
-
-
-```
-
-The catalog plugin also has one external route that needs to be bound for it to
-function: the `createComponent` route which should link to the page where the
-user can create components. In a typical setup the create component route will
-be linked to the Scaffolder plugin's template index page:
-
-```ts
-// packages/app/src/App.tsx
-import { catalogPlugin } from '@backstage/plugin-catalog';
-import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
-
-const app = createApp({
- // ...
- bindRoutes({ bind }) {
- bind(catalogPlugin.externalRoutes, {
- createComponent: scaffolderPlugin.routes.root,
- });
- },
-});
-```
-
-You may also want to add a link to the catalog index page to your sidebar:
-
-```tsx
-// packages/app/src/components/Root.tsx
-import HomeIcon from '@material-ui/icons/Home';
-
-// Somewhere within the
-;
-```
-
-This is all that is needed for the frontend part of the Catalog plugin to work!
-
-## Gotchas that we will fix
-
-Since the catalog plugin currently ships with a sentry plugin `InfoCard`
-installed by default, you'll need to set `sentry.organization` in your
-`app-config.yaml`. For example:
-
-```yaml
-sentry:
- organization: Acme Corporation
-```
-
-If you've created an app with an older version of `@backstage/create-app` or
-`@backstage/cli create-app`, be sure to remove the Welcome plugin from the app,
-as that will conflict with the catalog routes.
-
-## Installing @backstage/plugin-catalog-backend
-
-> **Note that if you used `npx @backstage/create-app`, the plugin is already
-> installed and you can skip to
-> [adding entries to the catalog](#adding-entries-to-the-catalog)**
-
-The catalog backend should be installed in your `backend` package, which is
-created as a part of `@backstage/create-app`. To install the package, run:
-
-```bash
-# From your Backstage root directory
-cd packages/backend
-yarn add @backstage/plugin-catalog-backend
-```
-
-### Adding the Plugin to your `packages/backend`
-
-You'll need to add the plugin to the `backend`'s router. You can do this by
-creating a file called `packages/backend/src/plugins/catalog.ts` with contents
-matching
-[catalog.ts in the create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts).
-
-Once the `catalog.ts` router setup file is in place, add the router to
-`packages/backend/src/index.ts`:
-
-```ts
-import catalog from './plugins/catalog';
-
-const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
-
-const apiRouter = Router();
-/** several different routers */
-apiRouter.use('/catalog', await catalog(catalogEnv));
-```
-
-### Adding Entries to the Catalog
-
-At this point the catalog backend is installed in your backend package, but you
-will not have any entities loaded.
-
-To get up and running and try out some templates quickly, you can add some of
-our example templates through static configuration. Add the following to the
-`catalog.locations` section in your `app-config.yaml`:
-
-```yaml
-catalog:
- locations:
- # Backstage Example Components
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml
-```
-
-### Running the Backend
-
-Finally, start up Backstage with the new configuration:
-
-```bash
-# Run from the root to start both backend and frontend
-yarn dev
-
-# Alternatively, run only the backend from its own package
-cd packages/backend
-yarn start
-```
-
-If you've also set up the frontend plugin, you should be ready to go browse the
-catalog at [localhost:3000](http://localhost:3000) now!
diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md
new file mode 100644
index 0000000000..cdd8166889
--- /dev/null
+++ b/docs/features/software-templates/configuration.md
@@ -0,0 +1,52 @@
+---
+id: configuration
+title: Software Template Configuration
+sidebar_label: Configuration
+description: Configuration options for Backstage Software Templates
+---
+
+Backstage software templates create source code, so your Backstage application
+needs to be set up to allow repository creation.
+
+This is done in your `app-config.yaml` by adding
+[Backstage integrations](https://backstage.io/docs/integrations/) for the
+appropriate source code repository for your organization.
+
+> Note: Integrations may already be set up as part of your `app-config.yaml`.
+
+The next step is to add
+[add templates](http://backstage.io/docs/features/software-templates/adding-templates)
+to your Backstage app.
+
+### GitHub
+
+For GitHub, you can configure who can see the new repositories that are created
+by specifying `visibility` option. Valid options are `public`, `private` and
+`internal`. The `internal` option is for GitHub Enterprise clients, which means
+public within the enterprise.
+
+```yaml
+scaffolder:
+ github:
+ visibility: public # or 'internal' or 'private'
+```
+
+### Disabling Docker in Docker situation (Optional)
+
+Software Templates use
+[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as a templating
+library. By default it will use the
+[scaffolder-backend/Cookiecutter](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)
+docker image.
+
+If you are running Backstage from a Docker container and you want to avoid
+calling a container inside a container, you can set up Cookiecutter in your own
+image, this will use the local installation instead.
+
+You can do so by including the following lines in the last step of your
+`Dockerfile`:
+
+```Dockerfile
+RUN apt-get update && apt-get install -y python3 python3-pip
+RUN pip3 install cookiecutter
+```
diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md
index 12ce6e3ed3..1434d62a29 100644
--- a/docs/features/software-templates/index.md
+++ b/docs/features/software-templates/index.md
@@ -17,10 +17,8 @@ locations like GitHub or GitLab.
### Getting Started
-> Be sure to have covered [Installing in your Backstage App](./installation.md)
-> for your separate App or
-> [Getting Started with Backstage](../../getting-started) for this repo before
-> proceeding.
+> Be sure to have covered
+> [Getting Started with Backstage](../../getting-started) before proceeding.
The Software Templates are available under `/create`. For local development you
should be able to reach them at `http://localhost:3000/create`.
diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md
deleted file mode 100644
index d5643ca955..0000000000
--- a/docs/features/software-templates/installation.md
+++ /dev/null
@@ -1,280 +0,0 @@
----
-id: installation
-title: Installing in your Backstage App
-description: Documentation on How to install Backstage App
----
-
-The scaffolder plugin comes in two packages, `@backstage/plugin-scaffolder` and
-`@backstage/plugin-scaffolder-backend`. Each has their own installation steps,
-outlined below.
-
-The Scaffolder plugin also depends on the Software Catalog. Instructions for how
-to set that up can be found [here](../software-catalog/installation.md).
-
-## Installing @backstage/plugin-scaffolder
-
-> **Note that if you used `npx @backstage/create-app`, the plugin may already be
-> present**
-
-The scaffolder frontend plugin should be installed in your `app` package, which
-is created as a part of `@backstage/create-app`. To install the package, run:
-
-```bash
-# From your Backstage root directory
-cd packages/app
-yarn add @backstage/plugin-scaffolder
-```
-
-### Adding the Plugin to your `packages/app`
-
-Add the root page that the Scaffolder plugin provides to your app. You can
-choose any path for the route, but we recommend the following:
-
-```tsx
-import { ScaffolderPage } from '@backstage/plugin-scaffolder';
-
-// Add to the top-level routes, directly within
-} />;
-```
-
-You may also want to add a link to the template index page to your sidebar:
-
-```tsx
-import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
-
-// Somewhere within the
-;
-```
-
-This is all that is needed for the frontend part of the Scaffolder plugin to
-work!
-
-## Installing @backstage/plugin-scaffolder-backend
-
-> **Note that if you used `npx @backstage/create-app`, the plugin may already be
-> present**
-
-The scaffolder backend should be installed in your `backend` package, which is
-created as a part of `@backstage/create-app`. To install the package, run:
-
-```bash
-# From your Backstage root directory
-cd packages/backend
-yarn add @backstage/plugin-scaffolder-backend
-```
-
-### Adding the Plugin to your `packages/backend`
-
-You'll need to add the plugin to the `backend`'s router. You can do this by
-creating a file called `packages/backend/src/plugins/scaffolder.ts` with the
-following contents to get you up and running quickly.
-
-```ts
-import {
- DockerContainerRunner,
- SingleHostDiscovery,
-} from '@backstage/backend-common';
-import {
- CookieCutter,
- createRouter,
- Preparers,
- Publishers,
- CreateReactAppTemplater,
- Templaters,
-} from '@backstage/plugin-scaffolder-backend';
-import type { PluginEnvironment } from '../types';
-import Docker from 'dockerode';
-import { CatalogClient } from '@backstage/catalog-client';
-
-export default async function createPlugin({
- logger,
- config,
- database,
- reader,
-}: PluginEnvironment) {
- const dockerClient = new Docker();
- const containerRunner = new DockerContainerRunner({ dockerClient });
-
- const cookiecutterTemplater = new CookieCutter({ containerRunner });
- const craTemplater = new CreateReactAppTemplater({ containerRunner });
- const templaters = new Templaters();
-
- templaters.register('cookiecutter', cookiecutterTemplater);
- templaters.register('cra', craTemplater);
-
- const preparers = await Preparers.fromConfig(config, { logger });
- const publishers = await Publishers.fromConfig(config, { logger });
-
- const discovery = SingleHostDiscovery.fromConfig(config);
- const catalogClient = new CatalogClient({ discoveryApi: discovery });
-
- return await createRouter({
- preparers,
- templaters,
- publishers,
- logger,
- config,
- database,
- catalogClient,
- reader,
- });
-}
-```
-
-Once the `scaffolder.ts` router setup file is in place, add the router to
-`packages/backend/src/index.ts`:
-
-```ts
-import scaffolder from './plugins/scaffolder';
-
-const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
-
-const apiRouter = Router();
-/* several router .use calls */
-
-/* add this line */
-apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
-```
-
-### Adding Templates
-
-At this point the scaffolder backend is installed in your backend package, but
-you will not have any templates available to use. These need to be added to the
-software catalog, as they are represented as entities of kind
-[Template](../software-catalog/descriptor-format.md#kind-template). You can find
-out more about adding templates [here](./adding-templates.md).
-
-To get up and running and try out some templates quickly, you can add some of
-our example templates through static configuration. Add the following to the
-`catalog.locations` section in your `app-config.yaml`:
-
-```yaml
-catalog:
- locations:
- # Backstage Example Templates
- - type: url
- target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml
- - type: url
- target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml
- - type: url
- target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml
-```
-
-### Runtime Dependencies / Configuration
-
-For the scaffolder backend plugin to function, you'll need to setup the
-integrations config in your `app-config.yaml`.
-
-You can find help for different providers below.
-
-> Note: Some of this configuration may already be set up as part of your
-> `app-config.yaml`. We're moving away from the duplicated config for
-> authentication in the `scaffolder` section and using `integrations` instead.
-
-#### GitHub
-
-The GitHub access token is retrieved from environment variables via the config.
-The config file needs to specify what environment variable the token is
-retrieved from. Your config should have the following objects.
-
-You can configure who can see the new repositories that the scaffolder creates
-by specifying `visibility` option. Valid options are `public`, `private` and
-`internal`. The `internal` option is for GitHub Enterprise clients, which means
-public within the enterprise.
-
-```yaml
-integrations:
- github:
- - host: github.com
- token: ${GITHUB_TOKEN}
-
-scaffolder:
- github:
- visibility: public # or 'internal' or 'private'
-```
-
-#### GitLab
-
-For GitLab, we currently support the configuration of the GitLab publisher and
-allows to configure the private access token and the base URL of a GitLab
-instance:
-
-```yaml
-integrations:
- gitlab:
- - host: gitlab.com
- token: ${GITLAB_TOKEN}
-```
-
-#### Bitbucket
-
-For Bitbucket there are two authentication methods supported. Either `token` or
-a combination of `appPassword` and `username`. It looks like either of the
-following:
-
-```yaml
-integrations:
- bitbucket:
- - host: bitbucket.org
- token: ${BITBUCKET_TOKEN}
-```
-
-or
-
-```yaml
-integrations:
- bitbucket:
- - host: bitbucket.org
- appPassword: ${BITBUCKET_APP_PASSWORD}
- username: ${BITBUCKET_USERNAME}
-```
-
-#### Azure DevOps
-
-For Azure DevOps we support both the preparer and publisher stage with the
-configuration of a private access token (PAT). For the publisher it's also
-required to define the base URL for the client to connect to the service. This
-will hopefully support on-prem installations as well but that has not been
-verified.
-
-```yaml
-integrations:
- azure:
- - host: dev.azure.com
- token: ${AZURE_TOKEN}
-```
-
-### Running the Backend
-
-Finally, make sure you have a local Docker daemon running, and start up the
-backend with the new configuration:
-
-```bash
-cd packages/backend
-GITHUB_TOKEN= yarn start
-```
-
-If you've also set up the frontend plugin, so you should be ready to go browse
-the templates at [localhost:3000/create](http://localhost:3000/create) now!
-
-### Disabling Docker in Docker situation (Optional)
-
-Software Templates use
-[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating
-library. By default it will use the
-[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile)
-docker image.
-
-If you are running Backstage from a Docker container and you want to avoid
-calling a container inside a container, you can set up Cookiecutter in your own
-image, this will use the local installation instead.
-
-You can do so by including the following lines in the last step of your
-`Dockerfile`:
-
-```Dockerfile
-RUN apt-get update && apt-get install -y python3 python3-pip
-RUN pip3 install cookiecutter
-```
diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md
index b9340330a8..3fdc942ac4 100644
--- a/docs/features/techdocs/creating-and-publishing.md
+++ b/docs/features/techdocs/creating-and-publishing.md
@@ -28,10 +28,10 @@ scratch.
### Use the documentation template
Your working Backstage instance should by default have a documentation template
-added. If not, follow these
-[instructions](../software-templates/installation.md#adding-templates) to add
-the documentation template. The template creates a component with only TechDocs
-configuration and default markdown files as below mentioned in manual
+added. If not, copy the catalog locations from the
+[create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs)
+to add the documentation template. The template creates a component with only
+TechDocs configuration and default markdown files as below mentioned in manual
documentation setup, and is otherwise empty.

diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md
index 87d23b45d5..805e321093 100644
--- a/docs/plugins/github-apps.md
+++ b/docs/plugins/github-apps.md
@@ -43,6 +43,10 @@ root of the project which you can then use as an `include` in your
`app-config.yaml`. You can go ahead and
[skip ahead](#including-in-integrations-config) if you've already got an app.
+Note that the created app will have a webhook that is disabled by default and
+points to `smee.io`, which is intended for local development. There's also
+currently no part of Backstage that makes use of the webhook.
+
### GitHub Enterprise
You have to create the GitHub Application manually using these
@@ -84,3 +88,12 @@ integrations:
apps:
- $include: example-backstage-app-credentials.yaml
```
+
+### Permissions for pull requests
+
+These are the minimum permissions required for creating a pull request with
+Backstage software templates:
+
+- Read and Write permissions for `Contents`.
+- Read and write permissions for `Pull Requests` and `Issues`.
+- Read permissions on `Metadata`.
diff --git a/microsite/sidebars.json b/microsite/sidebars.json
index f2ce425307..e0add7a007 100644
--- a/microsite/sidebars.json
+++ b/microsite/sidebars.json
@@ -33,7 +33,6 @@
"label": "Software Catalog",
"ids": [
"features/software-catalog/software-catalog-overview",
- "features/software-catalog/installation",
"features/software-catalog/configuration",
"features/software-catalog/system-model",
"features/software-catalog/descriptor-format",
@@ -62,7 +61,7 @@
"label": "Software Templates",
"ids": [
"features/software-templates/software-templates-index",
- "features/software-templates/installation",
+ "features/software-templates/configuration",
"features/software-templates/adding-templates",
"features/software-templates/writing-templates",
"features/software-templates/builtin-actions",
@@ -206,6 +205,7 @@
},
"auth/add-auth-provider",
"auth/using-auth",
+ "auth/identity-resolver",
"auth/auth-backend",
"auth/oauth",
"auth/auth-backend-classes",
diff --git a/mkdocs.yml b/mkdocs.yml
index 67b97b6ffb..4b2b29a997 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -30,7 +30,6 @@ nav:
- Core Features:
- Software Catalog:
- Overview: 'features/software-catalog/index.md'
- - Installing in your Backstage App: 'features/software-catalog/installation.md'
- Catalog Configuration: 'features/software-catalog/configuration.md'
- System Model: 'features/software-catalog/system-model.md'
- YAML File Format: 'features/software-catalog/descriptor-format.md'
@@ -49,7 +48,7 @@ nav:
- Troubleshooting: 'features/kubernetes/troubleshooting.md'
- Software Templates:
- Overview: 'features/software-templates/index.md'
- - Installing in your Backstage App: 'features/software-templates/installation.md'
+ - Configuration: 'features/software-templates/configuration.md'
- Adding your own Templates: 'features/software-templates/adding-templates.md'
- Writing Templates: 'features/software-templates/writing-templates.md'
- Builtin Actions: 'features/software-templates/builtin-actions.md'
@@ -133,6 +132,7 @@ nav:
- OneLogin: 'auth/onelogin/provider.md'
- Adding authentication providers: 'auth/add-auth-provider.md'
- Using authentication and identity: 'auth/using-auth.md'
+ - Sign in resolvers: 'auth/identity-resolver.md'
- Auth backend: 'auth/auth-backend.md'
- OAuth and OpenID Connect: 'auth/oauth.md'
- Auth backend classes: 'auth/auth-backend-classes.md'
diff --git a/packages/app/package.json b/packages/app/package.json
index 13b90d1af5..9d22f6bf28 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -8,6 +8,8 @@
"@backstage/cli": "^0.7.1",
"@backstage/core": "^0.7.13",
"@backstage/integration-react": "^0.1.3",
+ "@backstage/core-app-api": "^0.1.2",
+ "@backstage/core-components": "^0.1.1",
"@backstage/plugin-api-docs": "^0.5.0",
"@backstage/plugin-badges": "^0.2.2",
"@backstage/plugin-catalog": "^0.6.3",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index b14f9a3163..cc65cd65b3 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -14,13 +14,12 @@
* limitations under the License.
*/
+import { createApp, FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
- createApp,
- FlatRoutes,
OAuthRequestDialog,
SignInPage,
-} from '@backstage/core';
+} from '@backstage/core-components';
import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs';
import {
CatalogEntityPage,
@@ -65,6 +64,7 @@ const app = createApp({
// Custom icon example
alert: AlarmIcon,
},
+
components: {
SignInPage: props => {
return (
@@ -116,6 +116,7 @@ const routes = (
/>
} />
} />
+
} />
} />
} />
diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md
index 0dab9670de..68644bef87 100644
--- a/packages/catalog-model/api-report.md
+++ b/packages/catalog-model/api-report.md
@@ -10,7 +10,7 @@ import { JsonValue } from '@backstage/config';
import { SerializedError } from '@backstage/errors';
import * as yup from 'yup';
-// @public (undocumented)
+// @public @deprecated (undocumented)
export const analyzeLocationSchema: yup.ObjectSchema<{
location: LocationSpec;
}, object>;
@@ -316,7 +316,7 @@ export { LocationEntityV1alpha1 }
// @public (undocumented)
export const locationEntityV1alpha1Validator: KindValidator;
-// @public (undocumented)
+// @public @deprecated (undocumented)
export const locationSchema: yup.ObjectSchema;
// @public (undocumented)
@@ -326,7 +326,7 @@ export type LocationSpec = {
presence?: 'optional' | 'required';
};
-// @public (undocumented)
+// @public @deprecated (undocumented)
export const locationSpecSchema: yup.ObjectSchema;
// @public (undocumented)
diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts
index 4d2e602862..3a2fee5089 100644
--- a/packages/catalog-model/src/location/validation.ts
+++ b/packages/catalog-model/src/location/validation.ts
@@ -17,6 +17,7 @@
import * as yup from 'yup';
import { LocationSpec, Location } from './types';
+/** @deprecated */
export const locationSpecSchema = yup
.object({
type: yup.string().required(),
@@ -26,6 +27,7 @@ export const locationSpecSchema = yup
.noUnknown()
.required();
+/** @deprecated */
export const locationSchema = yup
.object({
id: yup.string().required(),
@@ -35,6 +37,7 @@ export const locationSchema = yup
.noUnknown()
.required();
+/** @deprecated */
export const analyzeLocationSchema = yup
.object<{ location: LocationSpec }>({
location: locationSpecSchema,
diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts
index 45671c2ead..0ffc1a08ff 100644
--- a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts
+++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts
@@ -120,6 +120,7 @@ export class GithubCreateAppServer {
redirect_url: `${baseUrl}/callback`,
hook_attributes: {
url: this.webhookUrl,
+ active: false,
},
};
const manifestJson = JSON.stringify(manifest).replace(/\"/g, '"');
diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md
index b85c9392d7..5aa709121a 100644
--- a/packages/core-app-api/api-report.md
+++ b/packages/core-app-api/api-report.md
@@ -234,6 +234,18 @@ export type ErrorBoundaryFallbackProps = {
resetError: () => void;
};
+// @public (undocumented)
+export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element;
+
+// @public (undocumented)
+export type FeatureFlaggedProps = {
+ children: ReactNode;
+} & ({
+ with: string;
+} | {
+ without: string;
+});
+
// @public (undocumented)
export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null;
diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx
index a5ba5c4bca..ecf06955b0 100644
--- a/packages/core-app-api/src/app/App.tsx
+++ b/packages/core-app-api/src/app/App.tsx
@@ -57,6 +57,7 @@ import {
} from '../extensions/traversal';
import { pluginCollector } from '../plugins/collectors';
import {
+ featureFlagCollector,
routeObjectCollector,
routeParentCollector,
routePathCollector,
@@ -215,7 +216,12 @@ export class PrivateAppImpl implements BackstageApp {
[],
);
- const { routePaths, routeParents, routeObjects } = useMemo(() => {
+ const {
+ routePaths,
+ routeParents,
+ routeObjects,
+ featureFlags,
+ } = useMemo(() => {
const result = traverseElementTree({
root: children,
discoverers: [childDiscoverer, routeElementDiscoverer],
@@ -224,6 +230,7 @@ export class PrivateAppImpl implements BackstageApp {
routeParents: routeParentCollector,
routeObjects: routeObjectCollector,
collectedPlugins: pluginCollector,
+ featureFlags: featureFlagCollector,
},
});
@@ -238,7 +245,6 @@ export class PrivateAppImpl implements BackstageApp {
// Initialize APIs once all plugins are available
this.getApiHolder();
-
return result;
}, [children]);
@@ -273,8 +279,14 @@ export class PrivateAppImpl implements BackstageApp {
}
}
}
+
+ // Go through the featureFlags returned from the traversal and
+ // register those now the configApi has been loaded
+ for (const name of featureFlags) {
+ featureFlagsApi.registerFlag({ name, pluginId: '' });
+ }
}
- }, [hasConfigApi, loadedConfig]);
+ }, [hasConfigApi, loadedConfig, featureFlags]);
if ('node' in loadedConfig) {
// Loading or error
diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts
index bec176fda8..654c547fca 100644
--- a/packages/core-app-api/src/index.test.ts
+++ b/packages/core-app-api/src/index.test.ts
@@ -37,6 +37,7 @@ describe('index', () => {
ConfigReader: expect.any(Function),
ErrorAlerter: expect.any(Function),
ErrorApiForwarder: expect.any(Function),
+ FeatureFlagged: expect.any(Function),
GithubAuth: expect.any(Function),
GitlabAuth: expect.any(Function),
GoogleAuth: expect.any(Function),
diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx
new file mode 100644
index 0000000000..e20af76ae1
--- /dev/null
+++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 { FeatureFlagged } from './FeatureFlagged';
+import { render } from '@testing-library/react';
+import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
+
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('FeatureFlagged', () => {
+ describe('with', () => {
+ it('should render contents when the feature flag is enabled', async () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => true);
+
+ const { queryByText } = render(
+
+
+
+
BACKSTAGE!
+
+
+ ,
+ );
+
+ expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
+ });
+ it('should not render contents when the feature flag is disabled', async () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => false);
+
+ const { queryByText } = render(
+
+
+
+
BACKSTAGE!
+
+
+ ,
+ );
+
+ expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
+ });
+ });
+ describe('without', () => {
+ it('should not render contents when the feature flag is enabled', async () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => true);
+
+ const { queryByText } = render(
+
+
+
+
BACKSTAGE!
+
+
+ ,
+ );
+
+ expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument();
+ });
+ it('should render contents when the feature flag is disabled', async () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => false);
+
+ const { queryByText } = render(
+
+
+
+
BACKSTAGE!
+
+
+ ,
+ );
+
+ expect(await queryByText('BACKSTAGE!')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx
new file mode 100644
index 0000000000..40ef445f92
--- /dev/null
+++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 {
+ featureFlagsApiRef,
+ useApi,
+ attachComponentData,
+} from '@backstage/core-plugin-api';
+import React, { ReactNode } from 'react';
+
+export type FeatureFlaggedProps = { children: ReactNode } & (
+ | { with: string }
+ | { without: string }
+);
+
+export const FeatureFlagged = (props: FeatureFlaggedProps) => {
+ const { children } = props;
+ const featureFlagApi = useApi(featureFlagsApiRef);
+ const isEnabled =
+ 'with' in props
+ ? featureFlagApi.isActive(props.with)
+ : !featureFlagApi.isActive(props.without);
+ return <>{isEnabled ? children : null}>;
+};
+
+attachComponentData(FeatureFlagged, 'core.featureFlagged', true);
diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx
index a9b83d1016..3ed5d5eb3a 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx
@@ -17,25 +17,36 @@
import { render, RenderResult } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom';
+import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { AppContext } from '../app';
import { AppContextProvider } from '../app/AppContext';
import { FlatRoutes } from './FlatRoutes';
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
+
function makeRouteRenderer(node: ReactNode) {
let rendered: RenderResult | undefined = undefined;
return (path: string) => {
const content = (
- ({
- NotFoundErrorPage: () => <>Not Found>,
- }),
- } as unknown) as AppContext
- }
- >
-
-
+
+ ({
+ NotFoundErrorPage: () => <>Not Found>,
+ }),
+ } as unknown) as AppContext
+ }
+ >
+
+
+
);
if (rendered) {
rendered.unmount();
diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx
index 54ef092965..ad1f764e6c 100644
--- a/packages/core-app-api/src/routing/FlatRoutes.tsx
+++ b/packages/core-app-api/src/routing/FlatRoutes.tsx
@@ -14,53 +14,16 @@
* limitations under the License.
*/
-import React, { ReactNode, Children, isValidElement, Fragment } from 'react';
+import React, { ReactNode } from 'react';
import { useRoutes } from 'react-router-dom';
-import { useApp } from '@backstage/core-plugin-api';
+import { useApp, useElementFilter } from '@backstage/core-plugin-api';
type RouteObject = {
path: string;
- element: JSX.Element;
+ element: ReactNode;
children?: RouteObject[];
};
-// Similar to the same function from react-router, this collects routes from the
-// children, but only the first level of routes
-function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] {
- return Children.toArray(childrenNode).flatMap(child => {
- if (!isValidElement(child)) {
- return [];
- }
-
- const { children } = child.props;
-
- if (child.type === Fragment) {
- return createRoutesFromChildren(children);
- }
-
- let path = child.props.path as string | undefined;
-
- // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
- if (path === '') {
- return [];
- }
- path = path?.replace(/\/\*$/, '') ?? '/';
-
- return [
- {
- path,
- element: child,
- children: children && [
- {
- path: '/*',
- element: children,
- },
- ],
- },
- ];
- });
-}
-
type FlatRoutesProps = {
children: ReactNode;
};
@@ -68,14 +31,41 @@ type FlatRoutesProps = {
export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => {
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
- const routes = createRoutesFromChildren(props.children)
- // Routes are sorted to work around a bug where prefixes are unexpectedly matched
- .sort((a, b) => b.path.localeCompare(a.path))
- // We make sure all routes have '/*' appended, except '/'
- .map(obj => {
- obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
- return obj;
- });
+ const routes = useElementFilter(props.children, elements =>
+ elements
+ .getElements<{ path?: string; children: ReactNode }>()
+ .flatMap(child => {
+ let path = child.props.path;
+
+ // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone
+ if (path === '') {
+ return [];
+ }
+ path = path?.replace(/\/\*$/, '') ?? '/';
+
+ return [
+ {
+ path,
+ element: child,
+ children: child.props.children
+ ? [
+ {
+ path: '/*',
+ element: child.props.children,
+ },
+ ]
+ : undefined,
+ },
+ ];
+ })
+ // Routes are sorted to work around a bug where prefixes are unexpectedly matched
+ .sort((a, b) => b.path.localeCompare(a.path))
+ // We make sure all routes have '/*' appended, except '/'
+ .map(obj => {
+ obj.path = obj.path === '/' ? '/' : `${obj.path}/*`;
+ return obj;
+ }),
+ );
// TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop
routes.push({
diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx
index e940a2cada..b9ca45f54a 100644
--- a/packages/core-app-api/src/routing/collectors.tsx
+++ b/packages/core-app-api/src/routing/collectors.tsx
@@ -19,6 +19,7 @@ import { RouteRef } from '@backstage/core-plugin-api';
import { BackstageRouteObject } from './types';
import { getComponentData } from '../extensions';
import { createCollector } from '../extensions/traversal';
+import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged';
function getMountPoint(node: ReactElement): RouteRef | undefined {
const element: ReactNode = node.props?.element;
@@ -171,3 +172,13 @@ export const routeObjectCollector = createCollector(
return parentObj;
},
);
+
+export const featureFlagCollector = createCollector(
+ () => new Set(),
+ (acc, node) => {
+ if (node.type === FeatureFlagged) {
+ const props = node.props as FeatureFlaggedProps;
+ acc.add('with' in props ? props.with : props.without);
+ }
+ },
+);
diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts
index 7982333f4b..b37b51e919 100644
--- a/packages/core-app-api/src/routing/index.ts
+++ b/packages/core-app-api/src/routing/index.ts
@@ -15,3 +15,5 @@
*/
export { FlatRoutes } from './FlatRoutes';
+export { FeatureFlagged } from './FeatureFlagged';
+export type { FeatureFlaggedProps } from './FeatureFlagged';
diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md
index a6f196847a..90ea1f5e46 100644
--- a/packages/core-plugin-api/api-report.md
+++ b/packages/core-plugin-api/api-report.md
@@ -8,6 +8,7 @@ import { BackstageTheme } from '@backstage/theme';
import { ComponentType } from 'react';
import { Config } from '@backstage/config';
import { default as React_2 } from 'react';
+import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { SvgIconProps } from '@material-ui/core';
@@ -227,6 +228,20 @@ export type DiscoveryApi = {
// @public (undocumented)
export const discoveryApiRef: ApiRef;
+// @public
+export interface ElementCollection {
+ findComponentData(query: {
+ key: string;
+ }): T[];
+ getElements(): Array>;
+ selectByComponentData(query: {
+ key: string;
+ withStrictError?: string;
+ }): ElementCollection;
+}
+
// @public
export type ErrorApi = {
post(error: Error_2, context?: ErrorContext): void;
@@ -514,6 +529,9 @@ export function useApiHolder(): ApiHolder;
// @public (undocumented)
export const useApp: () => AppContext;
+// @public
+export function useElementFilter(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T;
+
// @public (undocumented)
export type UserFlags = {};
diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json
index 816727ed9e..48706a5877 100644
--- a/packages/core-plugin-api/package.json
+++ b/packages/core-plugin-api/package.json
@@ -42,6 +42,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.0",
+ "@backstage/core-app-api": "^0.1.2",
"@backstage/test-utils": "^0.1.13",
"@backstage/test-utils-core": "^0.1.1",
"@testing-library/jest-dom": "^5.10.1",
diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts
index 26a0c597b1..0ee1447b4e 100644
--- a/packages/core-plugin-api/src/extensions/index.ts
+++ b/packages/core-plugin-api/src/extensions/index.ts
@@ -20,3 +20,5 @@ export {
createRoutableExtension,
createComponentExtension,
} from './extensions';
+export { useElementFilter } from './useElementFilter';
+export type { ElementCollection } from './useElementFilter';
diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx
new file mode 100644
index 0000000000..73fbb9e5a3
--- /dev/null
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx
@@ -0,0 +1,377 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { ReactNode } from 'react';
+import { useElementFilter } from './useElementFilter';
+import { renderHook } from '@testing-library/react-hooks';
+import { attachComponentData } from './componentData';
+import { featureFlagsApiRef } from '../apis';
+import {
+ ApiProvider,
+ ApiRegistry,
+ LocalStorageFeatureFlags,
+} from '@backstage/core-app-api';
+
+const WRAPPING_COMPONENT_KEY = 'core.blob.testing';
+const INNER_COMPONENT_KEY = 'core.blob2.testing';
+
+const WrappingComponent = (_props: { children: ReactNode }) => null;
+attachComponentData(WrappingComponent, WRAPPING_COMPONENT_KEY, {
+ message: 'hey! im wrapping component data',
+});
+const InnerComponent = () => null;
+attachComponentData(InnerComponent, INNER_COMPONENT_KEY, {
+ message: 'hey! im the inner component',
+});
+const MockComponent = (_props: { children: ReactNode }) => null;
+
+const FeatureFlagComponent = (_props: {
+ children: ReactNode;
+ with?: string;
+ without?: string;
+}) => null;
+attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true);
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('useElementFilter', () => {
+ it('should select elements based on a component data key', () => {
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ expect(result.current[0].key).toBe('.$.$first');
+ expect(result.current[1].key).toBe('.$.$second');
+ });
+
+ it('should find componentData', () => {
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements.findComponentData({ key: WRAPPING_COMPONENT_KEY }),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ expect(result.current[0]).toEqual({
+ message: 'hey! im wrapping component data',
+ });
+ expect(result.current[1]).toEqual({
+ message: 'hey! im wrapping component data',
+ });
+ });
+
+ it('can be combined to together to filter the selection', () => {
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .findComponentData({ key: INNER_COMPONENT_KEY }),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ expect(result.current[0]).toEqual({
+ message: 'hey! im the inner component',
+ });
+ expect(result.current[1]).toEqual({
+ message: 'hey! im the inner component',
+ });
+ });
+
+ describe('FeatureFlags', () => {
+ describe('with', () => {
+ it('should not discover deeper than the feature gate if the feature flag is disabled', () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => false);
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(1);
+ expect(result.current[0].key).toContain('second');
+ });
+
+ it('should discover components behind a feature flag if the flag is enabled', () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => true);
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ });
+ });
+
+ describe('without', () => {
+ it('should discover deeper than the feature gate if the feature flag is disabled', () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => false);
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ });
+
+ it('should not discover components behind a feature flag if the flag is enabled', () => {
+ jest
+ .spyOn(mockFeatureFlagsApi, 'isActive')
+ .mockImplementation(() => true);
+ const tree = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(1);
+ });
+ });
+ });
+
+ it('should reject when strict mode is enabled with the correct string', () => {
+ const tree = (
+
+
Hello
+
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({
+ key: WRAPPING_COMPONENT_KEY,
+ withStrictError: 'Could not find component',
+ })
+ .findComponentData({ key: INNER_COMPONENT_KEY }),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.error.message).toEqual('Could not find component');
+ });
+
+ it('should support fragments and text node iteration', () => {
+ jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true);
+ const tree = (
+ <>
+
+ <>
+
+
+
+
+
+ >
+
+ hello my name
+ <>
+
+
+
+ >
+
+ is text
+
+
+ >
+ );
+
+ const { result } = renderHook(
+ props =>
+ useElementFilter(props.tree, elements =>
+ elements
+ .selectByComponentData({ key: WRAPPING_COMPONENT_KEY })
+ .getElements(),
+ ),
+ {
+ initialProps: { tree },
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current.length).toBe(2);
+ });
+});
diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
new file mode 100644
index 0000000000..0549472ab2
--- /dev/null
+++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 {
+ Children,
+ Fragment,
+ isValidElement,
+ ReactNode,
+ ReactElement,
+ useMemo,
+} from 'react';
+import { getComponentData } from './componentData';
+import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis';
+
+function selectChildren(
+ rootNode: ReactNode,
+ featureFlagsApi: FeatureFlagsApi,
+ selector?: (element: ReactElement) => boolean,
+ strictError?: string,
+): Array> {
+ return Children.toArray(rootNode).flatMap(node => {
+ if (!isValidElement(node)) {
+ return [];
+ }
+
+ if (node.type === Fragment) {
+ return selectChildren(
+ node.props.children,
+ featureFlagsApi,
+ selector,
+ strictError,
+ );
+ }
+
+ if (getComponentData(node, 'core.featureFlagged')) {
+ const props = node.props as { with: string } | { without: string };
+ const isEnabled =
+ 'with' in props
+ ? featureFlagsApi.isActive(props.with)
+ : !featureFlagsApi.isActive(props.without);
+ if (isEnabled) {
+ return selectChildren(
+ node.props.children,
+ featureFlagsApi,
+ selector,
+ strictError,
+ );
+ }
+ return [];
+ }
+
+ if (selector === undefined || selector(node)) {
+ return [node];
+ }
+
+ if (strictError) {
+ throw new Error(strictError);
+ }
+
+ return selectChildren(
+ node.props.children,
+ featureFlagsApi,
+ selector,
+ strictError,
+ );
+ });
+}
+
+/**
+ * A querying interface tailored to traversing a set of selected React elements
+ * and extracting data.
+ *
+ * Methods prefixed with `selectBy` are used to narrow the set of selected elements.
+ *
+ * Methods prefixed with `find` return concrete data using a deep traversal of the set.
+ *
+ * Methods prefixed with `get` return concrete data using a shallow traversal of the set.
+ */
+export interface ElementCollection {
+ /**
+ * Narrows the set of selected components by doing a deep traversal and
+ * only including those that have defined component data for the given `key`.
+ *
+ * Whether an element in the tree has component data set for the given key
+ * is determined by whether `getComponentData` returns undefined.
+ *
+ * The traversal does not continue deeper past elements that match the criteria,
+ * and it also includes the root children in the selection, meaning that if the,
+ * of all the currently selected elements contain data for the given key, this
+ * method is a no-op.
+ *
+ * If `withStrictError` is set, the resulting selection must be a full match, meaning
+ * there may be no elements that were excluded in the selection. If the selection
+ * is not a clean match, an error will be throw with `withStrictError` as the message.
+ */
+ selectByComponentData(query: {
+ key: string;
+ withStrictError?: string;
+ }): ElementCollection;
+
+ /**
+ * Finds all elements using the same criteria as `selectByComponentData`, but
+ * returns the actual component data of each of those elements instead.
+ */
+ findComponentData(query: { key: string }): T[];
+
+ /**
+ * Returns all of the elements currently selected by this collection.
+ */
+ getElements(): Array<
+ ReactElement
+ >;
+}
+
+class Collection implements ElementCollection {
+ constructor(
+ private readonly node: ReactNode,
+ private readonly featureFlagsApi: FeatureFlagsApi,
+ ) {}
+
+ selectByComponentData(query: { key: string; withStrictError?: string }) {
+ const selection = selectChildren(
+ this.node,
+ this.featureFlagsApi,
+ node => getComponentData(node, query.key) !== undefined,
+ query.withStrictError,
+ );
+ return new Collection(selection, this.featureFlagsApi);
+ }
+
+ findComponentData(query: { key: string }): T[] {
+ const selection = selectChildren(
+ this.node,
+ this.featureFlagsApi,
+ node => getComponentData(node, query.key) !== undefined,
+ );
+ return selection
+ .map(node => getComponentData(node, query.key))
+ .filter((data: T | undefined): data is T => data !== undefined);
+ }
+
+ getElements(): Array<
+ ReactElement
+ > {
+ return selectChildren(this.node, this.featureFlagsApi) as Array<
+ ReactElement
+ >;
+ }
+}
+
+/**
+ * useElementFilter is a utility that helps you narrow down and retrieve data
+ * from a React element tree, typically operating on the `children` property
+ * passed in to a component. A common use-case is to construct declarative APIs
+ * where a React component defines its behavior based on its children, such as
+ * the relationship between `Routes` and `Route` in `react-router`.
+ *
+ * The purpose of this hook is similar to `React.Children.map`, and it expands upon
+ * it to also handle traversal of fragments and Backstage specific things like the
+ * `FeatureFlagged` component.
+ *
+ * The return value of the hook is computed by the provided filter function, but
+ * with added memoization based on the input `node`. If further memoization
+ * dependencies are used in the filter function, they should be added to the
+ * third `dependencies` argument, just like `useMemo`, `useEffect`, etc.
+ */
+export function useElementFilter(
+ node: ReactNode,
+ filterFn: (arg: ElementCollection) => T,
+ dependencies: any[] = [],
+) {
+ const featureFlagsApi = useApi(featureFlagsApiRef);
+ const elements = new Collection(node, featureFlagsApi);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return useMemo(() => filterFn(elements), [node, ...dependencies]);
+}
diff --git a/packages/core-plugin-api/src/index.test.ts b/packages/core-plugin-api/src/index.test.ts
index 9414a6b730..5d29ead619 100644
--- a/packages/core-plugin-api/src/index.test.ts
+++ b/packages/core-plugin-api/src/index.test.ts
@@ -40,6 +40,7 @@ describe('index', () => {
useApi: expect.any(Function),
useApiHolder: expect.any(Function),
useApp: expect.any(Function),
+ useElementFilter: expect.any(Function),
useRouteRef: expect.any(Function),
useRouteRefParams: expect.any(Function),
withApis: expect.any(Function),
diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md
index a1df0f294c..4658e413c1 100644
--- a/packages/create-app/CHANGELOG.md
+++ b/packages/create-app/CHANGELOG.md
@@ -1,5 +1,12 @@
# @backstage/create-app
+## 0.3.27
+
+### Patch Changes
+
+- Updated dependencies
+ - @backstage/plugin-scaffolder-backend@0.12.2
+
## 0.3.26
### Patch Changes
diff --git a/packages/create-app/package.json b/packages/create-app/package.json
index 95add27d6a..0eb5d9e29c 100644
--- a/packages/create-app/package.json
+++ b/packages/create-app/package.json
@@ -1,7 +1,7 @@
{
"name": "@backstage/create-app",
"description": "Create app package for Backstage",
- "version": "0.3.26",
+ "version": "0.3.27",
"private": false,
"publishConfig": {
"access": "public"
diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md
new file mode 100644
index 0000000000..5a09cfceea
--- /dev/null
+++ b/plugins/api-docs/api-report.md
@@ -0,0 +1,115 @@
+## API Report File for "@backstage/plugin-api-docs"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiEntity } from '@backstage/catalog-model';
+import { ApiRef } from '@backstage/core';
+import { BackstagePlugin } from '@backstage/core';
+import { CatalogTableRow } from '@backstage/plugin-catalog';
+import { Entity } from '@backstage/catalog-model';
+import { ExternalRouteRef } from '@backstage/core';
+import { default as React_2 } from 'react';
+import { RouteRef } from '@backstage/core';
+import { TableColumn } from '@backstage/core';
+import { UserListFilterKind } from '@backstage/plugin-catalog-react';
+
+// @public (undocumented)
+export const ApiDefinitionCard: (_: Props) => JSX.Element;
+
+// @public (undocumented)
+export type ApiDefinitionWidget = {
+ type: string;
+ title: string;
+ component: (definition: string) => React_2.ReactElement;
+ rawLanguage?: string;
+};
+
+// @public (undocumented)
+export const apiDocsConfigRef: ApiRef;
+
+// @public (undocumented)
+const apiDocsPlugin: BackstagePlugin<{
+ root: RouteRef;
+}, {
+ createComponent: ExternalRouteRef;
+}>;
+
+export { apiDocsPlugin }
+
+export { apiDocsPlugin as plugin }
+
+// @public (undocumented)
+export const ApiExplorerPage: ({ initiallySelectedFilter, columns, }: ApiExplorerPageProps) => JSX.Element;
+
+// @public (undocumented)
+export const ApiTypeTitle: ({ apiEntity }: {
+ apiEntity: ApiEntity;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const AsyncApiDefinitionWidget: ({ definition }: Props_5) => JSX.Element;
+
+// @public (undocumented)
+export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element;
+
+// @public (undocumented)
+export const ConsumingComponentsCard: ({ variant }: Props_6) => JSX.Element;
+
+// @public (undocumented)
+export function defaultDefinitionWidgets(): ApiDefinitionWidget[];
+
+// @public (undocumented)
+export const EntityApiDefinitionCard: (_: {
+ apiEntity?: ApiEntity | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityConsumedApisCard: ({ variant }: {
+ entity?: Entity| undefined;
+ variant?: "gridItem" | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityConsumingComponentsCard: ({ variant }: {
+ entity?: Entity| undefined;
+ variant?: "gridItem" | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityHasApisCard: ({ variant }: {
+ variant?: "gridItem" | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityProvidedApisCard: ({ variant }: {
+ entity?: Entity| undefined;
+ variant?: "gridItem" | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityProvidingComponentsCard: ({ variant }: {
+ entity?: Entity| undefined;
+ variant?: "gridItem" | undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const HasApisCard: ({ variant }: Props_3) => JSX.Element;
+
+// @public (undocumented)
+export const OpenApiDefinitionWidget: ({ definition }: Props_8) => JSX.Element;
+
+// @public (undocumented)
+export const PlainApiDefinitionWidget: ({ definition, language }: Props_9) => JSX.Element;
+
+// @public (undocumented)
+export const ProvidedApisCard: ({ variant }: Props_4) => JSX.Element;
+
+// @public (undocumented)
+export const ProvidingComponentsCard: ({ variant }: Props_7) => JSX.Element;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md
new file mode 100644
index 0000000000..4f59102265
--- /dev/null
+++ b/plugins/app-backend/api-report.md
@@ -0,0 +1,28 @@
+## API Report File for "@backstage/plugin-app-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { Config } from '@backstage/config';
+import express from 'express';
+import { Logger } from 'winston';
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export interface RouterOptions {
+ appPackageName: string;
+ // (undocumented)
+ config: Config;
+ disableConfigInjection?: boolean;
+ // (undocumented)
+ logger: Logger;
+ staticFallbackHandler?: express.Handler;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md
new file mode 100644
index 0000000000..dea5500d53
--- /dev/null
+++ b/plugins/auth-backend/api-report.md
@@ -0,0 +1,211 @@
+## API Report File for "@backstage/plugin-auth-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { CatalogApi } from '@backstage/catalog-client';
+import { Config } from '@backstage/config';
+import express from 'express';
+import { JSONWebKey } from 'jose';
+import { Logger } from 'winston';
+import { PluginDatabaseManager } from '@backstage/backend-common';
+import { PluginEndpointDiscovery } from '@backstage/backend-common';
+import { Profile } from 'passport';
+
+// @public (undocumented)
+export type AuthProviderFactory = (options: AuthProviderFactoryOptions) => AuthProviderRouteHandlers;
+
+// @public (undocumented)
+export type AuthProviderFactoryOptions = {
+ providerId: string;
+ globalConfig: AuthProviderConfig;
+ config: Config;
+ logger: Logger;
+ tokenIssuer: TokenIssuer;
+ discovery: PluginEndpointDiscovery;
+ catalogApi: CatalogApi;
+ identityResolver?: ExperimentalIdentityResolver;
+};
+
+// @public
+export interface AuthProviderRouteHandlers {
+ frameHandler(req: express.Request, res: express.Response): Promise;
+ logout?(req: express.Request, res: express.Response): Promise;
+ refresh?(req: express.Request, res: express.Response): Promise;
+ start(req: express.Request, res: express.Response): Promise;
+}
+
+// @public (undocumented)
+export type AuthResponse = {
+ providerInfo: ProviderInfo;
+ profile: ProfileInfo;
+ backstageIdentity?: BackstageIdentity;
+};
+
+// @public (undocumented)
+export type BackstageIdentity = {
+ id: string;
+ idToken?: string;
+};
+
+// @public (undocumented)
+export function createRouter({ logger, config, discovery, database, providerFactories, }: RouterOptions): Promise;
+
+// @public (undocumented)
+export const defaultAuthProviderFactories: {
+ [providerId: string]: AuthProviderFactory;
+};
+
+// @public (undocumented)
+export const encodeState: (state: OAuthState) => string;
+
+// @public (undocumented)
+export const ensuresXRequestedWith: (req: express.Request) => boolean;
+
+// @public
+export class IdentityClient {
+ constructor(options: {
+ discovery: PluginEndpointDiscovery;
+ issuer: string;
+ });
+ authenticate(token: string | undefined): Promise;
+ static getBearerToken(authorizationHeader: string | undefined): string | undefined;
+ listPublicKeys(): Promise<{
+ keys: JSONWebKey[];
+ }>;
+ }
+
+// @public (undocumented)
+export class OAuthAdapter implements AuthProviderRouteHandlers {
+ constructor(handlers: OAuthHandlers, options: Options);
+ // (undocumented)
+ frameHandler(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ static fromConfig(config: AuthProviderConfig, handlers: OAuthHandlers, options: Pick): OAuthAdapter;
+ // (undocumented)
+ logout(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ refresh(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ start(req: express.Request, res: express.Response): Promise;
+}
+
+// @public (undocumented)
+export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
+ constructor(handlers: Map);
+ // (undocumented)
+ frameHandler(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ logout(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ static mapConfig(config: Config, factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers): OAuthEnvironmentHandler;
+ // (undocumented)
+ refresh(req: express.Request, res: express.Response): Promise;
+ // (undocumented)
+ start(req: express.Request, res: express.Response): Promise;
+}
+
+// @public
+export interface OAuthHandlers {
+ handler(req: express.Request): Promise<{
+ response: AuthResponse;
+ refreshToken?: string;
+ }>;
+ logout?(): Promise;
+ refresh?(req: OAuthRefreshRequest): Promise>;
+ start(req: OAuthStartRequest): Promise;
+}
+
+// @public (undocumented)
+export type OAuthProviderInfo = {
+ accessToken: string;
+ idToken?: string;
+ expiresInSeconds?: number;
+ scope: string;
+ refreshToken?: string;
+};
+
+// @public
+export type OAuthProviderOptions = {
+ clientId: string;
+ clientSecret: string;
+ callbackUrl: string;
+};
+
+// @public (undocumented)
+export type OAuthRefreshRequest = express.Request<{}> & {
+ scope: string;
+ refreshToken: string;
+};
+
+// @public (undocumented)
+export type OAuthResponse = AuthResponse;
+
+// @public (undocumented)
+export type OAuthResult = {
+ fullProfile: Profile;
+ params: {
+ id_token?: string;
+ scope: string;
+ expires_in: number;
+ };
+ accessToken: string;
+ refreshToken?: string;
+};
+
+// @public (undocumented)
+export type OAuthStartRequest = express.Request<{}> & {
+ scope: string;
+ state: OAuthState;
+};
+
+// @public (undocumented)
+export type OAuthState = {
+ nonce: string;
+ env: string;
+};
+
+// @public (undocumented)
+export const postMessageResponse: (res: express.Response, appOrigin: string, response: WebMessageResponse) => void;
+
+// @public
+export type ProfileInfo = {
+ email?: string;
+ displayName?: string;
+ picture?: string;
+};
+
+// @public (undocumented)
+export const readState: (stateString: string) => OAuthState;
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ database: PluginDatabaseManager;
+ // (undocumented)
+ discovery: PluginEndpointDiscovery;
+ // (undocumented)
+ logger: Logger;
+ // (undocumented)
+ providerFactories?: ProviderFactories;
+}
+
+// @public (undocumented)
+export const verifyNonce: (req: express.Request, providerId: string) => void;
+
+// @public
+export type WebMessageResponse = {
+ type: 'authorization_response';
+ response: AuthResponse;
+} | {
+ type: 'authorization_response';
+ error: Error;
+};
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts
index 1c6192d5c5..ac8f974cdb 100644
--- a/plugins/auth-backend/src/identity/TokenFactory.ts
+++ b/plugins/auth-backend/src/identity/TokenFactory.ts
@@ -67,13 +67,14 @@ export class TokenFactory implements TokenIssuer {
const iss = this.issuer;
const sub = params.claims.sub;
+ const ent = params.claims.ent;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
- this.logger.info(`Issuing token for ${sub}`);
+ this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
- return JWS.sign({ iss, sub, aud, iat, exp }, key, {
+ return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
alg: key.alg,
kid: key.kid,
});
diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts
index 6e984d41cc..2080b08cb6 100644
--- a/plugins/auth-backend/src/identity/types.ts
+++ b/plugins/auth-backend/src/identity/types.ts
@@ -28,6 +28,8 @@ export type TokenParams = {
claims: {
/** The token subject, i.e. User ID */
sub: string;
+ /** A list of entity references that the user claims ownership through */
+ ent?: string[];
};
};
diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts
index 0ec65b7a0b..57c2b3345b 100644
--- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts
+++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts
@@ -16,6 +16,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
+import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
describe('CatalogIdentityClient', () => {
@@ -29,25 +30,36 @@ describe('CatalogIdentityClient', () => {
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
};
+ const tokenIssuer: jest.Mocked = {
+ issueToken: jest.fn(),
+ listPublicKeys: jest.fn(),
+ };
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
+ tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
- catalogApi: catalogApi as CatalogApi,
+ catalogApi: catalogApi,
+ tokenIssuer: tokenIssuer,
});
- client.findUser({ annotations: { key: 'value' } });
+ await client.findUser({ annotations: { key: 'value' } });
- expect(catalogApi.getEntities).toBeCalledWith(
+ expect(catalogApi.getEntities).toHaveBeenCalledWith(
{
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
},
},
- undefined,
+ { token: 'my-token' },
);
+ expect(tokenIssuer.issueToken).toHaveBeenCalledWith({
+ claims: {
+ sub: 'backstage.io/auth-backend',
+ },
+ });
});
});
diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts
index abd3624df2..947b8dac1f 100644
--- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts
+++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts
@@ -17,6 +17,7 @@
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
+import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record;
@@ -27,9 +28,11 @@ type UserQuery = {
*/
export class CatalogIdentityClient {
private readonly catalogApi: CatalogApi;
+ private readonly tokenIssuer: TokenIssuer;
- constructor(options: { catalogApi: CatalogApi }) {
+ constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }) {
this.catalogApi = options.catalogApi;
+ this.tokenIssuer = options.tokenIssuer;
}
/**
@@ -37,10 +40,7 @@ export class CatalogIdentityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
*/
- async findUser(
- query: UserQuery,
- options?: { token?: string },
- ): Promise {
+ async findUser(query: UserQuery): Promise {
const filter: Record = {
kind: 'user',
};
@@ -48,7 +48,11 @@ export class CatalogIdentityClient {
filter[`metadata.annotations.${key}`] = value;
}
- const { items } = await this.catalogApi.getEntities({ filter }, options);
+ // TODO(Rugvip): cache the token
+ const token = await this.tokenIssuer.issueToken({
+ claims: { sub: 'backstage.io/auth-backend' },
+ });
+ const { items } = await this.catalogApi.getEntities({ filter }, { token });
if (items.length !== 1) {
if (items.length > 1) {
diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts
new file mode 100644
index 0000000000..c60198dc98
--- /dev/null
+++ b/plugins/auth-backend/src/lib/catalog/helpers.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 {
+ RELATION_MEMBER_OF,
+ stringifyEntityRef,
+ UserEntity,
+} from '@backstage/catalog-model';
+import { TokenParams } from '../../identity';
+
+export function getEntityClaims(entity: UserEntity): TokenParams['claims'] {
+ const userRef = stringifyEntityRef(entity);
+
+ const membershipRefs =
+ entity.relations
+ ?.filter(
+ r =>
+ r.type === RELATION_MEMBER_OF &&
+ r.target.kind.toLocaleLowerCase('en-US') === 'group',
+ )
+ .map(r => stringifyEntityRef(r.target)) ?? [];
+
+ return {
+ sub: userRef,
+ ent: [userRef, ...membershipRefs],
+ };
+}
diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts
index f15469668f..fbd58081e3 100644
--- a/plugins/auth-backend/src/lib/catalog/index.ts
+++ b/plugins/auth-backend/src/lib/catalog/index.ts
@@ -15,3 +15,4 @@
*/
export { CatalogIdentityClient } from './CatalogIdentityClient';
+export { getEntityClaims } from './helpers';
diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts
index 2615a2d8e5..8bff8b250b 100644
--- a/plugins/auth-backend/src/providers/google/index.ts
+++ b/plugins/auth-backend/src/providers/google/index.ts
@@ -14,5 +14,9 @@
* limitations under the License.
*/
-export { createGoogleProvider } from './provider';
+export {
+ createGoogleProvider,
+ googleDefaultSignInResolver,
+ googleEmailSignInResolver,
+} from './provider';
export type { GoogleProviderOptions } from './provider';
diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts
new file mode 100644
index 0000000000..45df5bd319
--- /dev/null
+++ b/plugins/auth-backend/src/providers/google/provider.test.ts
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { GoogleAuthProvider } from './provider';
+import * as helpers from '../../lib/passport/PassportStrategyHelper';
+import { OAuthResult } from '../../lib/oauth';
+import { getVoidLogger } from '@backstage/backend-common';
+import { TokenIssuer } from '../../identity/types';
+import { CatalogIdentityClient } from '../../lib/catalog';
+
+const mockFrameHandler = (jest.spyOn(
+ helpers,
+ 'executeFrameHandlerStrategy',
+) as unknown) as jest.MockedFunction<
+ () => Promise<{ result: OAuthResult; privateInfo: any }>
+>;
+
+describe('createGoogleProvider', () => {
+ it('should auth', async () => {
+ const tokenIssuer = {
+ issueToken: jest.fn(),
+ listPublicKeys: jest.fn(),
+ };
+ const catalogIdentityClient = {
+ findUser: jest.fn(),
+ };
+
+ const provider = new GoogleAuthProvider({
+ logger: getVoidLogger(),
+ catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
+ tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
+ authHandler: async ({ fullProfile }) => ({
+ profile: {
+ email: fullProfile.emails![0]!.value,
+ displayName: fullProfile.displayName,
+ picture: 'http://google.com/lols',
+ },
+ }),
+ clientId: 'mock',
+ clientSecret: 'mock',
+ callbackUrl: 'mock',
+ });
+
+ mockFrameHandler.mockResolvedValueOnce({
+ result: {
+ fullProfile: {
+ emails: [{ value: 'conrad@example.com' }],
+ displayName: 'Conrad',
+ id: 'conrad',
+ provider: 'google',
+ },
+ params: {
+ id_token: 'idToken',
+ scope: 'scope',
+ expires_in: 123,
+ },
+ accessToken: 'accessToken',
+ },
+ privateInfo: {
+ refreshToken: 'wacka',
+ },
+ });
+ const { response } = await provider.handler({} as any);
+ expect(response).toEqual({
+ providerInfo: {
+ accessToken: 'accessToken',
+ expiresInSeconds: 123,
+ idToken: 'idToken',
+ scope: 'scope',
+ },
+ profile: {
+ email: 'conrad@example.com',
+ displayName: 'Conrad',
+ picture: 'http://google.com/lols',
+ },
+ });
+ });
+});
diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts
index a714674447..ace0c8e132 100644
--- a/plugins/auth-backend/src/providers/google/provider.ts
+++ b/plugins/auth-backend/src/providers/google/provider.ts
@@ -17,8 +17,8 @@
import express from 'express';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
-import { Logger } from 'winston';
-import { CatalogIdentityClient } from '../../lib/catalog';
+import { TokenIssuer } from '../../identity/types';
+import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import {
encodeState,
OAuthAdapter,
@@ -27,8 +27,8 @@ import {
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
- OAuthStartRequest,
OAuthResult,
+ OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -38,30 +38,40 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
-import { AuthProviderFactory, RedirectInfo } from '../types';
-import { TokenIssuer } from '../../identity/types';
+import {
+ AuthProviderFactory,
+ AuthHandler,
+ RedirectInfo,
+ SignInResolver,
+} from '../types';
+import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
- logger: Logger;
- identityClient: CatalogIdentityClient;
+ signInResolver?: SignInResolver;
+ authHandler: AuthHandler;
tokenIssuer: TokenIssuer;
+ catalogIdentityClient: CatalogIdentityClient;
+ logger: Logger;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
- private readonly logger: Logger;
- private readonly identityClient: CatalogIdentityClient;
+ private readonly signInResolver?: SignInResolver;
+ private readonly authHandler: AuthHandler;
private readonly tokenIssuer: TokenIssuer;
+ private readonly catalogIdentityClient: CatalogIdentityClient;
+ private readonly logger: Logger;
constructor(options: Options) {
- this.logger = options.logger;
- this.identityClient = options.identityClient;
+ this.signInResolver = options.signInResolver;
+ this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
- // TODO: throw error if env variables not set?
+ this.catalogIdentityClient = options.catalogIdentityClient;
+ this.logger = options.logger;
this._strategy = new GoogleStrategy(
{
clientID: options.clientId,
@@ -111,18 +121,8 @@ export class GoogleAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);
- const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
-
return {
- response: await this.populateIdentity({
- providerInfo: {
- idToken: result.params.id_token,
- accessToken: result.accessToken,
- scope: result.params.scope,
- expiresInSeconds: result.params.expires_in,
- },
- profile,
- }),
+ response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -133,89 +133,170 @@ export class GoogleAuthProvider implements OAuthHandlers {
req.refreshToken,
req.scope,
);
-
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
- const profile = makeProfileInfo(fullProfile, params.id_token);
-
- return this.populateIdentity({
- providerInfo: {
- accessToken,
- idToken: params.id_token,
- expiresInSeconds: params.expires_in,
- scope: params.scope,
- },
- profile,
+ return this.handleResult({
+ fullProfile,
+ params,
+ accessToken,
+ refreshToken: req.refreshToken,
});
}
- private async populateIdentity(
- response: OAuthResponse,
- ): Promise {
- const { profile } = response;
+ private async handleResult(result: OAuthResult) {
+ const { profile } = await this.authHandler(result);
- if (!profile.email) {
- throw new Error('Google profile contained no email');
- }
+ const response: OAuthResponse = {
+ providerInfo: {
+ idToken: result.params.id_token,
+ accessToken: result.accessToken,
+ scope: result.params.scope,
+ expiresInSeconds: result.params.expires_in,
+ },
+ profile,
+ };
- try {
- const token = await this.tokenIssuer.issueToken({
- claims: { sub: 'backstage.io/auth-backend' },
- });
- const user = await this.identityClient.findUser(
+ if (this.signInResolver) {
+ response.backstageIdentity = await this.signInResolver(
{
- annotations: {
- 'google.com/email': profile.email,
- },
+ result,
+ profile,
},
- { token },
- );
-
- return {
- ...response,
- backstageIdentity: {
- id: user.metadata.name,
+ {
+ tokenIssuer: this.tokenIssuer,
+ catalogIdentityClient: this.catalogIdentityClient,
+ logger: this.logger,
},
- };
- } catch (error) {
- this.logger.warn(
- `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
- return {
- ...response,
- backstageIdentity: { id: profile.email.split('@')[0] },
- };
}
+
+ return response;
}
}
-export type GoogleProviderOptions = {};
+export const googleEmailSignInResolver: SignInResolver = async (
+ info,
+ ctx,
+) => {
+ const { profile } = info;
+
+ if (!profile.email) {
+ throw new Error('Google profile contained no email');
+ }
+
+ const entity = await ctx.catalogIdentityClient.findUser({
+ annotations: {
+ 'google.com/email': profile.email,
+ },
+ });
+
+ const claims = getEntityClaims(entity);
+ const token = await ctx.tokenIssuer.issueToken({ claims });
+
+ return { id: entity.metadata.name, entity, token };
+};
+
+export const googleDefaultSignInResolver: SignInResolver = async (
+ info,
+ ctx,
+) => {
+ const { profile } = info;
+
+ if (!profile.email) {
+ throw new Error('Google profile contained no email');
+ }
+
+ let userId: string;
+ try {
+ const entity = await ctx.catalogIdentityClient.findUser({
+ annotations: {
+ 'google.com/email': profile.email,
+ },
+ });
+ userId = entity.metadata.name;
+ } catch (error) {
+ ctx.logger.warn(
+ `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
+ );
+ userId = profile.email.split('@')[0];
+ }
+
+ const token = await ctx.tokenIssuer.issueToken({
+ claims: { sub: userId, ent: [`user:default/${userId}`] },
+ });
+
+ return { id: userId, token };
+};
+
+export type GoogleProviderOptions = {
+ /**
+ * The profile transformation function used to verify and convert the auth response
+ * into the profile that will be presented to the user.
+ */
+ authHandler?: AuthHandler;
+
+ /**
+ * Configure sign-in for this provider, without it the provider can not be used to sign users in.
+ */
+ /**
+ * Maps an auth result to a Backstage identity for the user.
+ *
+ * Set to `'email'` to use the default email-based sign in resolver, which will search
+ * the catalog for a single user entity that has a matching `google.com/email` annotation.
+ */
+ signIn?: {
+ resolver?: SignInResolver;
+ };
+};
export const createGoogleProvider = (
- _options?: GoogleProviderOptions,
+ options?: GoogleProviderOptions,
): AuthProviderFactory => {
return ({
providerId,
globalConfig,
config,
- logger,
tokenIssuer,
catalogApi,
+ logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
+ const catalogIdentityClient = new CatalogIdentityClient({
+ catalogApi,
+ tokenIssuer,
+ });
+
+ const authHandler: AuthHandler = options?.authHandler
+ ? options.authHandler
+ : async ({ fullProfile, params }) => ({
+ profile: makeProfileInfo(fullProfile, params.id_token),
+ });
+
+ const signInResolverFn =
+ options?.signIn?.resolver ?? googleDefaultSignInResolver;
+
+ const signInResolver: SignInResolver = info =>
+ signInResolverFn(info, {
+ catalogIdentityClient,
+ tokenIssuer,
+ logger,
+ });
+
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
- logger,
+ signInResolver,
+ authHandler,
tokenIssuer,
- identityClient: new CatalogIdentityClient({ catalogApi }),
+ catalogIdentityClient,
+ logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts
index 3f3d16f28f..eedc79cf09 100644
--- a/plugins/auth-backend/src/providers/index.ts
+++ b/plugins/auth-backend/src/providers/index.ts
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+export * from './google';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts
index 700af3ebef..e4bbaa091c 100644
--- a/plugins/auth-backend/src/providers/types.ts
+++ b/plugins/auth-backend/src/providers/types.ts
@@ -16,10 +16,12 @@
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
+import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity/types';
+import { CatalogIdentityClient } from '../lib/catalog';
export type AuthProviderConfig = {
/**
@@ -148,14 +150,30 @@ export type AuthResponse = {
export type BackstageIdentity = {
/**
- * The backstage user ID.
+ * An opaque ID that uniquely identifies the user within Backstage.
+ *
+ * This is typically the same as the user entity `metadata.name`.
*/
id: string;
/**
- * An ID token that can be used to authenticate the user within Backstage.
+ * This is deprecated, use `token` instead.
+ * @deprecated
*/
idToken?: string;
+
+ /**
+ * The token used to authenticate the user within Backstage.
+ */
+ token?: string;
+
+ /**
+ * The entity that the user is represented by within Backstage.
+ *
+ * This entity may or may not exist within the Catalog, and it can be used
+ * to read and store additional metadata about the user.
+ */
+ entity?: Entity;
};
/**
@@ -179,3 +197,38 @@ export type ProfileInfo = {
*/
picture?: string;
};
+
+export type SignInInfo = {
+ /**
+ * The simple profile passed down for use in the frontend.
+ */
+ profile: ProfileInfo;
+
+ /**
+ * The authentication result that was received from the authentication provider.
+ */
+ result: AuthResult;
+};
+
+export type SignInResolver = (
+ info: SignInInfo,
+ context: {
+ tokenIssuer: TokenIssuer;
+ catalogIdentityClient: CatalogIdentityClient;
+ logger: Logger;
+ },
+) => Promise;
+
+export type AuthHandlerResult = { profile: ProfileInfo };
+
+/**
+ * The AuthHandler function is called every time the user authenticates using the provider.
+ *
+ * The handler should return a profile that represents the session for the user in the frontend.
+ *
+ * Throwing an error in the function will cause the authentication to fail, making it
+ * possible to use this function as a way to limit access to a certain group of users.
+ */
+export type AuthHandler = (
+ input: AuthResult,
+) => Promise;
diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md
new file mode 100644
index 0000000000..1523d685b4
--- /dev/null
+++ b/plugins/badges-backend/api-report.md
@@ -0,0 +1,113 @@
+## API Report File for "@backstage/plugin-badges-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { CatalogApi } from '@backstage/catalog-client';
+import { Config } from '@backstage/config';
+import { Entity } from '@backstage/catalog-model';
+import express from 'express';
+import { PluginEndpointDiscovery } from '@backstage/backend-common';
+
+// @public (undocumented)
+export interface Badge {
+ color?: string;
+ description?: string;
+ kind?: 'entity';
+ label: string;
+ labelColor?: string;
+ link?: string;
+ message: string;
+ style?: BadgeStyle;
+}
+
+// @public (undocumented)
+export const BADGE_STYLES: readonly ["plastic", "flat", "flat-square", "for-the-badge", "social"];
+
+// @public (undocumented)
+export type BadgeBuilder = {
+ getBadges(): Promise;
+ createBadgeJson(options: BadgeOptions): Promise;
+ createBadgeSvg(options: BadgeOptions): Promise;
+};
+
+// @public (undocumented)
+export interface BadgeContext {
+ // (undocumented)
+ badgeUrl: string;
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ entity?: Entity;
+}
+
+// @public (undocumented)
+export interface BadgeFactories {
+ // (undocumented)
+ [id: string]: BadgeFactory;
+}
+
+// @public (undocumented)
+export interface BadgeFactory {
+ // (undocumented)
+ createBadge(context: BadgeContext): Badge;
+}
+
+// @public (undocumented)
+export type BadgeInfo = {
+ id: string;
+};
+
+// @public (undocumented)
+export type BadgeOptions = {
+ badgeInfo: BadgeInfo;
+ context: BadgeContext;
+};
+
+// @public (undocumented)
+export type BadgeSpec = {
+ id: string;
+ badge: Badge;
+ url: string;
+ markdown: string;
+};
+
+// @public (undocumented)
+export type BadgeStyle = typeof BADGE_STYLES[number];
+
+// @public (undocumented)
+export const createDefaultBadgeFactories: () => BadgeFactories;
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export class DefaultBadgeBuilder implements BadgeBuilder {
+ constructor(factories: BadgeFactories);
+ // (undocumented)
+ createBadgeJson(options: BadgeOptions): Promise;
+ // (undocumented)
+ createBadgeSvg(options: BadgeOptions): Promise;
+ // (undocumented)
+ getBadges(): Promise;
+ }
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ badgeBuilder?: BadgeBuilder;
+ // (undocumented)
+ badgeFactories?: BadgeFactories;
+ // (undocumented)
+ catalog?: CatalogApi;
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ discovery: PluginEndpointDiscovery;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/badges/api-report.md b/plugins/badges/api-report.md
new file mode 100644
index 0000000000..dfe7cbdae8
--- /dev/null
+++ b/plugins/badges/api-report.md
@@ -0,0 +1,21 @@
+## API Report File for "@backstage/plugin-badges"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { BackstagePlugin } from '@backstage/core';
+
+// @public (undocumented)
+export const badgesPlugin: BackstagePlugin<{}, {}>;
+
+// @public (undocumented)
+export const EntityBadgesDialog: ({ open, onClose }: {
+ open: boolean;
+ onClose?: (() => any) | undefined;
+}) => JSX.Element;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/bitrise/api-report.md b/plugins/bitrise/api-report.md
new file mode 100644
index 0000000000..c07178111d
--- /dev/null
+++ b/plugins/bitrise/api-report.md
@@ -0,0 +1,22 @@
+## API Report File for "@backstage/plugin-bitrise"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { BackstagePlugin } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+
+// @public (undocumented)
+export const bitrisePlugin: BackstagePlugin<{}, {}>;
+
+// @public (undocumented)
+export const EntityBitriseContent: () => JSX.Element;
+
+// @public (undocumented)
+export const isBitriseAvailable: (entity: Entity) => boolean;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md
index 2f06b9c062..ce97bb7a54 100644
--- a/plugins/catalog-backend/README.md
+++ b/plugins/catalog-backend/README.md
@@ -1,21 +1,80 @@
# Catalog Backend
-This is the backend part of the default catalog plugin.
+This is the backend for the default Backstage [software
+catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview).
+This provides an API for consumers such as the frontend [catalog
+plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog).
-It comes with a builtin database backed implementation of the catalog, that can store
-and serve your catalog for you.
+It comes with a builtin database-backed implementation of the catalog that can
+store and serve your catalog for you.
-It can also act as a bridge to your existing catalog solutions, either ingesting their
-data to store in the database, or by effectively proxying calls to an external catalog
-service.
+It can also act as a bridge to your existing catalog solutions, either ingesting
+data to store in the database, or by effectively proxying calls to an
+external catalog service.
-## Getting Started
+## Installation
-This backend plugin can be started in a standalone mode from directly in this package
-with `yarn start`. However, it will have limited functionality and that process is
-most convenient when developing the catalog backend plugin itself.
+This `@backstage/plugin-catalog-backend` package comes installed by default in
+any Backstage application created with `npx @backstage/create-app`, so
+installation is not usually required.
-To evaluate the catalog and have a greater amount of functionality available, instead do
+To check if you already have the package, look under
+`packages/backend/package.json`, in the `dependencies` block, for
+`@backstage/plugin-catalog-backend`. The instructions below walk through
+restoring the plugin, if you previously removed it.
+
+### Install the package
+
+```bash
+# From your Backstage root directory
+cd packages/backend
+yarn add @backstage/plugin-catalog-backend
+```
+
+### Adding the plugin to your `packages/backend`
+
+You'll need to add the plugin to the router in your `backend` package. You can
+do this by creating a file called `packages/backend/src/plugins/catalog.ts` with
+contents matching [catalog.ts in the create-app
+template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts).
+
+With the `catalog.ts` router setup in place, add the router to
+`packages/backend/src/index.ts`:
+
+```diff
++import catalog from './plugins/catalog';
+
+async function main() {
+ ...
+ const createEnv = makeCreateEnv(config);
+
++ const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
+ const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
+
+ const apiRouter = Router();
++ apiRouter.use('/catalog', await catalog(catalogEnv));
+ ...
+ apiRouter.use(notFoundHandler());
+
+```
+
+### Adding catalog entities
+
+At this point the `catalog-backend` is installed in your backend package, but
+you will not have any catalog entities loaded. See [Catalog
+Configuration](https://backstage.io/docs/features/software-catalog/configuration)
+for how to add locations, or copy the catalog locations from the [create-app
+template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs)
+to get up and running quickly.
+
+## Development
+
+This backend plugin can be started in a standalone mode from directly in this
+package with `yarn start`. However, it will have limited functionality and that
+process is most convenient when developing the catalog backend plugin itself.
+
+To evaluate the catalog and have a greater amount of functionality available,
+run the entire Backstage example application from the root folder:
```bash
# in one terminal window, run this from from the very root of the Backstage project
@@ -23,9 +82,10 @@ cd packages/backend
yarn start
```
-This will launch the full example backend, populated some example entities.
+This will launch both frontend and backend in the same window, populated with
+some example entities.
## Links
-- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog)
-- [The Backstage homepage](https://backstage.io)
+- [catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
+ is the frontend interface for this plugin.
diff --git a/plugins/catalog-graphql/api-report.md b/plugins/catalog-graphql/api-report.md
new file mode 100644
index 0000000000..cbe325f4c5
--- /dev/null
+++ b/plugins/catalog-graphql/api-report.md
@@ -0,0 +1,25 @@
+## API Report File for "@backstage/plugin-catalog-graphql"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { Config } from '@backstage/config';
+import { GraphQLModule } from '@graphql-modules/core';
+import { Logger } from 'winston';
+
+// @public (undocumented)
+export function createModule(options: ModuleOptions): Promise;
+
+// @public (undocumented)
+export interface ModuleOptions {
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ logger: Logger;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md
new file mode 100644
index 0000000000..34baed46b5
--- /dev/null
+++ b/plugins/catalog-import/api-report.md
@@ -0,0 +1,130 @@
+## API Report File for "@backstage/plugin-catalog-import"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+import { BackstagePlugin } from '@backstage/core';
+import { CatalogApi } from '@backstage/catalog-client';
+import { ConfigApi } from '@backstage/core';
+import { Control } from 'react-hook-form';
+import { DiscoveryApi } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+import { EntityName } from '@backstage/catalog-model';
+import { FieldErrors } from 'react-hook-form';
+import { IdentityApi } from '@backstage/core';
+import { InfoCardVariants } from '@backstage/core';
+import { OAuthApi } from '@backstage/core';
+import { default as React_2 } from 'react';
+import { RouteRef } from '@backstage/core';
+import { ScmIntegrationRegistry } from '@backstage/integration';
+import { SubmitHandler } from 'react-hook-form';
+import { TextFieldProps } from '@material-ui/core/TextField/TextField';
+import { UnpackNestedValue } from 'react-hook-form';
+import { UseControllerOptions } from 'react-hook-form';
+import { UseFormMethods } from 'react-hook-form';
+import { UseFormOptions } from 'react-hook-form';
+
+// @public (undocumented)
+export type AnalyzeResult = {
+ type: 'locations';
+ locations: Array<{
+ target: string;
+ entities: EntityName[];
+ }>;
+} | {
+ type: 'repository';
+ url: string;
+ integrationType: string;
+ generatedEntities: PartialEntity[];
+};
+
+// @public (undocumented)
+export const AutocompleteTextField: ({ name, options, required, control, errors, rules, loading, loadingText, helperText, errorHelperText, textFieldProps, }: Props_4) => JSX.Element;
+
+// @public (undocumented)
+export interface CatalogImportApi {
+ // (undocumented)
+ analyzeUrl(url: string): Promise;
+ // (undocumented)
+ submitPullRequest(options: {
+ repositoryUrl: string;
+ fileContent: string;
+ title: string;
+ body: string;
+ }): Promise<{
+ link: string;
+ location: string;
+ }>;
+}
+
+// @public (undocumented)
+export const catalogImportApiRef: ApiRef;
+
+// @public (undocumented)
+export class CatalogImportClient implements CatalogImportApi {
+ constructor(options: {
+ discoveryApi: DiscoveryApi;
+ githubAuthApi: OAuthApi;
+ identityApi: IdentityApi;
+ scmIntegrationsApi: ScmIntegrationRegistry;
+ catalogApi: CatalogApi;
+ });
+ // (undocumented)
+ analyzeUrl(url: string): Promise;
+ // (undocumented)
+ submitPullRequest({ repositoryUrl, fileContent, title, body, }: {
+ repositoryUrl: string;
+ fileContent: string;
+ title: string;
+ body: string;
+ }): Promise<{
+ link: string;
+ location: string;
+ }>;
+}
+
+// @public (undocumented)
+export const CatalogImportPage: (opts: StepperProviderOpts) => JSX.Element;
+
+// @public (undocumented)
+const catalogImportPlugin: BackstagePlugin<{
+ importPage: RouteRef;
+}, {}>;
+
+export { catalogImportPlugin }
+
+export { catalogImportPlugin as plugin }
+
+// @public
+export function defaultGenerateStepper(flow: ImportFlows, defaults: StepperProvider): StepperProvider;
+
+// @public (undocumented)
+export const EntityListComponent: ({ locations, collapsed, locationListItemIcon, onItemClick, firstListItem, withLinks, }: Props_2) => JSX.Element;
+
+// @public (undocumented)
+export const ImportStepper: ({ initialUrl, generateStepper, variant, opts, }: Props) => JSX.Element;
+
+// @public
+export const PreparePullRequestForm: >({ defaultValues, onSubmit, render, }: Props_5) => JSX.Element;
+
+// @public (undocumented)
+export const PreviewCatalogInfoComponent: ({ repositoryUrl, entities, classes, }: Props_6) => JSX.Element;
+
+// @public (undocumented)
+export const PreviewPullRequestComponent: ({ title, description, classes, }: Props_7) => JSX.Element;
+
+// @public (undocumented)
+export const Router: (opts: StepperProviderOpts) => JSX.Element;
+
+// @public
+export const StepInitAnalyzeUrl: ({ onAnalysis, analysisUrl, disablePullRequest, }: Props_3) => JSX.Element;
+
+// @public (undocumented)
+export const StepPrepareCreatePullRequest: ({ analyzeResult, onPrepare, onGoBack, renderFormFields, defaultTitle, defaultBody, }: Props_8) => JSX.Element;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md
index 611d2989e8..8527c8da7c 100644
--- a/plugins/catalog/README.md
+++ b/plugins/catalog/README.md
@@ -1,26 +1,107 @@
# Backstage Catalog Frontend
-This is the frontend part of the default catalog plugin.
+This is the React frontend for the default Backstage [software
+catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview).
+This package supplies interfaces related to listing catalog entities or showing
+more information about them on entity pages.
-It will implement the core API for handling your catalog of software, and
-supply the base views to show and manage them.
+## Installation
-## Getting Started
+This `@backstage/plugin-catalog` package comes installed by default in any
+Backstage application created with `npx @backstage/create-app`, so installation
+is not usually required.
-This frontend plugin can be started in a standalone mode from directly in this package
-with `yarn start`. However, it will have limited functionality and that process is
-most convenient when developing the catalog frontend plugin itself.
+To check if you already have the package, look under
+`packages/app/package.json`, in the `dependencies` block, for
+`@backstage/plugin-catalog`. The instructions below walk through restoring the
+plugin, if you previously removed it.
-To evaluate the catalog and have a greater amount of functionality available, from the main
-Backstage root folder, instead do:
+### Install the package
+
+```bash
+# From your Backstage root directory
+cd packages/app
+yarn add @backstage/plugin-catalog
+```
+
+### Add the plugin to your `packages/app`
+
+Add the two pages that the catalog plugin provides to your app. You can choose
+any name for these routes, but we recommend the following:
+
+```diff
+// packages/app/src/App.tsx
+import {
+ CatalogIndexPage,
+ CatalogEntityPage,
+} from '@backstage/plugin-catalog';
+import { entityPage } from './components/catalog/EntityPage';
+
+
++ } />
++ }>
++ {/*
++ This is the root of the custom entity pages for your app, refer to the example app
++ in the main repo or the output of @backstage/create-app for an example
++ */}
++ {entityPage}
++
+ ...
+
+```
+
+The catalog plugin also has one external route that needs to be bound for it to
+function: the `createComponent` route which should link to the page where the
+user can create components. In a typical setup the create component route will
+be linked to the scaffolder plugin's template index page:
+
+```diff
+// packages/app/src/App.tsx
++import { catalogPlugin } from '@backstage/plugin-catalog';
++import { scaffolderPlugin } from '@backstage/plugin-scaffolder';
+
+const app = createApp({
+ // ...
+ bindRoutes({ bind }) {
++ bind(catalogPlugin.externalRoutes, {
++ createComponent: scaffolderPlugin.routes.root,
++ });
+ },
+});
+```
+
+You may also want to add a link to the catalog index page to your application
+sidebar:
+
+```diff
+// packages/app/src/components/Root/Root.tsx
++import HomeIcon from '@material-ui/icons/Home';
+
+export const Root = ({ children }: PropsWithChildren<{}>) => (
+
+
++
+ ...
+
+```
+
+## Development
+
+This frontend plugin can be started in a standalone mode from directly in this
+package with `yarn start`. However, it will have limited functionality and that
+process is most convenient when developing the catalog frontend plugin itself.
+
+To evaluate the catalog and have a greater amount of functionality available,
+run the entire Backstage example application from the root folder:
```bash
yarn dev
```
-This will launch both frontend and backend in the same window, populated with some example entities.
+This will launch both frontend and backend in the same window, populated with
+some example entities.
## Links
-- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
-- [The Backstage homepage](https://backstage.io)
+- [catalog-backend](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend)
+ provides the backend API for this frontend.
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 4e35cc7618..0af476b95a 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.13",
"@backstage/catalog-model": "^0.8.3",
"@backstage/core": "^0.7.13",
+ "@backstage/core-plugin-api": "^0.1.2",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.6",
"@backstage/integration-react": "^0.1.3",
@@ -54,6 +55,7 @@
},
"devDependencies": {
"@backstage/cli": "^0.7.1",
+ "@backstage/core-app-api": "^0.1.2",
"@backstage/dev-utils": "^0.1.17",
"@backstage/test-utils": "^0.1.13",
"@testing-library/jest-dom": "^5.10.1",
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
index dfd27631f8..aceb1da6f2 100644
--- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx
@@ -29,6 +29,7 @@ import {
Progress,
RoutedTabs,
} from '@backstage/core';
+import { useElementFilter } from '@backstage/core-plugin-api';
import {
EntityContext,
EntityRefLinks,
@@ -37,14 +38,7 @@ import {
} from '@backstage/plugin-catalog-react';
import { Box, TabProps } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
-import {
- Children,
- default as React,
- Fragment,
- isValidElement,
- useContext,
- useState,
-} from 'react';
+import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
@@ -58,46 +52,14 @@ type SubRoute = {
tabProps?: TabProps;
};
+const dataKey = 'plugin.catalog.entityLayoutRoute';
+
const Route: (props: SubRoute) => null = () => null;
+attachComponentData(Route, dataKey, true);
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
-function createSubRoutesFromChildren(
- childrenProps: React.ReactNode,
- entity: Entity | undefined,
-): SubRoute[] {
- // Directly comparing child.type with Route will not work with in
- // combination with react-hot-loader in storybook
- // https://github.com/gaearon/react-hot-loader/issues/304
- const routeType = (
-
-
-
- ).type;
-
- return Children.toArray(childrenProps).flatMap(child => {
- if (!isValidElement(child)) {
- return [];
- }
-
- if (child.type === Fragment) {
- return createSubRoutesFromChildren(child.props.children, entity);
- }
-
- if (child.type !== routeType) {
- throw new Error('Child of EntityLayout must be an EntityLayout.Route');
- }
-
- const { path, title, children, if: condition, tabProps } = child.props;
- if (condition && entity && !condition(entity)) {
- return [];
- }
-
- return [{ path, title, children, tabProps }];
- });
-}
-
const EntityLayoutTitle = ({
entity,
title,
@@ -195,7 +157,29 @@ export const EntityLayout = ({
const { kind, namespace, name } = useEntityCompoundName();
const { entity, loading, error } = useContext(EntityContext);
- const routes = createSubRoutesFromChildren(children, entity);
+ const routes = useElementFilter(children, elements =>
+ elements
+ .selectByComponentData({
+ key: dataKey,
+ withStrictError: 'Child of EntityLayout must be an EntityLayout.Route',
+ })
+ .getElements() // all nodes, element data, maintain structure or not?
+ .flatMap(({ props }) => {
+ if (props.if && entity && !props.if(entity)) {
+ return [];
+ }
+
+ return [
+ {
+ path: props.path,
+ title: props.title,
+ children: props.children,
+ tabProps: props.tabProps,
+ },
+ ];
+ }),
+ );
+
const { headerTitle, headerType } = headerProps(
kind,
namespace,
diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
index 20b9aaba4f..5b21266d44 100644
--- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
+++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
@@ -20,6 +20,19 @@ import { render } from '@testing-library/react';
import React from 'react';
import { isKind } from './conditions';
import { EntitySwitch } from './EntitySwitch';
+import { featureFlagsApiRef } from '@backstage/core-plugin-api';
+import {
+ LocalStorageFeatureFlags,
+ ApiProvider,
+ ApiRegistry,
+} from '@backstage/core-app-api';
+
+const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
+const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+);
describe('EntitySwitch', () => {
it('should switch child when entity switches', () => {
@@ -32,15 +45,17 @@ describe('EntitySwitch', () => {
);
const rendered = render(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
@@ -48,15 +63,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -64,15 +81,17 @@ describe('EntitySwitch', () => {
expect(rendered.queryByText('C')).not.toBeInTheDocument();
rendered.rerender(
-
- {content}
- ,
+
+
+ {content}
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
@@ -88,24 +107,28 @@ describe('EntitySwitch', () => {
};
const rendered = render(
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
expect(rendered.queryByText('A')).toBeInTheDocument();
expect(rendered.queryByText('B')).not.toBeInTheDocument();
rendered.rerender(
-
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
expect(rendered.queryByText('A')).not.toBeInTheDocument();
diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
index a3ede42288..4b12a7249b 100644
--- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
+++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx
@@ -16,49 +16,40 @@
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
+import { PropsWithChildren, ReactNode } from 'react';
import {
- Children,
- Fragment,
- isValidElement,
- PropsWithChildren,
- ReactNode,
- useMemo,
-} from 'react';
+ attachComponentData,
+ useElementFilter,
+} from '@backstage/core-plugin-api';
+
+const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch';
const EntitySwitchCase = (_: {
if?: (entity: Entity) => boolean;
children: ReactNode;
}) => null;
+attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true);
+
type SwitchCase = {
if?: (entity: Entity) => boolean;
children: JSX.Element;
};
-function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] {
- return Children.toArray(childrenNode).flatMap(child => {
- if (!isValidElement(child)) {
- return [];
- }
-
- if (child.type === Fragment) {
- return createSwitchCasesFromChildren(child.props.children);
- }
-
- if (child.type !== EntitySwitchCase) {
- throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`);
- }
-
- const { if: condition, children } = child.props;
- return [{ if: condition, children }];
- });
-}
-
export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => {
const { entity } = useEntity();
- const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [
- children,
- ]);
+ const switchCases = useElementFilter(children, collection =>
+ collection
+ .selectByComponentData({
+ key: ENTITY_SWITCH_KEY,
+ withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case',
+ })
+ .getElements()
+ .flatMap((element: React.ReactElement) => {
+ const { if: condition, children: elementsChildren } = element.props;
+ return [{ if: condition, children: elementsChildren }];
+ }),
+ );
const matchingCase = switchCases.find(switchCase =>
switchCase.if ? switchCase.if(entity) : true,
diff --git a/plugins/circleci/api-report.md b/plugins/circleci/api-report.md
new file mode 100644
index 0000000000..6c774cc6b0
--- /dev/null
+++ b/plugins/circleci/api-report.md
@@ -0,0 +1,81 @@
+## API Report File for "@backstage/plugin-circleci"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+import { BackstagePlugin } from '@backstage/core';
+import { BuildStepAction } from 'circleci-api';
+import { BuildSummary } from 'circleci-api';
+import { BuildSummaryResponse } from 'circleci-api';
+import { BuildWithSteps } from 'circleci-api';
+import { CircleCIOptions } from 'circleci-api';
+import { DiscoveryApi } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+import { GitType } from 'circleci-api';
+import { Me } from 'circleci-api';
+import { RouteRef } from '@backstage/core';
+
+export { BuildStepAction }
+
+export { BuildSummary }
+
+export { BuildWithSteps }
+
+// @public (undocumented)
+export const CIRCLECI_ANNOTATION = "circleci.com/project-slug";
+
+// @public (undocumented)
+export class CircleCIApi {
+ constructor(options: Options);
+ // (undocumented)
+ getBuild(buildNumber: number, options: Partial): Promise;
+ // (undocumented)
+ getBuilds({ limit, offset }: {
+ limit: number;
+ offset: number;
+ }, options: Partial): Promise;
+ // (undocumented)
+ getUser(options: Partial): Promise;
+ // (undocumented)
+ retry(buildNumber: number, options: Partial): Promise;
+}
+
+// @public (undocumented)
+export const circleCIApiRef: ApiRef;
+
+// @public (undocumented)
+export const circleCIBuildRouteRef: RouteRef;
+
+// @public (undocumented)
+const circleCIPlugin: BackstagePlugin<{}, {}>;
+
+export { circleCIPlugin }
+
+export { circleCIPlugin as plugin }
+
+// @public (undocumented)
+export const circleCIRouteRef: RouteRef;
+
+// @public (undocumented)
+export const EntityCircleCIContent: (_props: {
+ entity?: Entity| undefined;
+}) => JSX.Element;
+
+export { GitType }
+
+// @public (undocumented)
+const isCircleCIAvailable: (entity: Entity) => boolean;
+
+export { isCircleCIAvailable }
+
+export { isCircleCIAvailable as isPluginApplicableToEntity }
+
+// @public (undocumented)
+export const Router: (_props: Props) => JSX.Element;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/cloudbuild/api-report.md b/plugins/cloudbuild/api-report.md
new file mode 100644
index 0000000000..58dc59c175
--- /dev/null
+++ b/plugins/cloudbuild/api-report.md
@@ -0,0 +1,283 @@
+## API Report File for "@backstage/plugin-cloudbuild"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+import { BackstagePlugin } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+import { OAuthApi } from '@backstage/core';
+import { RouteRef } from '@backstage/core';
+
+// @public (undocumented)
+export type ActionsGetWorkflowResponseData = {
+ id: string;
+ status: string;
+ source: Source;
+ createTime: string;
+ startTime: string;
+ steps: Step[];
+ timeout: string;
+ projectId: string;
+ logsBucket: string;
+ sourceProvenance: SourceProvenance;
+ buildTriggerId: string;
+ options: Options;
+ logUrl: string;
+ substitutions: Substitutions;
+ tags: string[];
+ queueTtl: string;
+ name: string;
+ finishTime: any;
+ results: Results;
+ timing: Timing2;
+};
+
+// @public (undocumented)
+export interface ActionsListWorkflowRunsForRepoResponseData {
+ // (undocumented)
+ builds: ActionsGetWorkflowResponseData[];
+}
+
+// @public (undocumented)
+export interface BUILD {
+ // (undocumented)
+ endTime: string;
+ // (undocumented)
+ startTime: string;
+}
+
+// @public (undocumented)
+export const CLOUDBUILD_ANNOTATION = "google.com/cloudbuild-project-slug";
+
+// @public (undocumented)
+export type CloudbuildApi = {
+ listWorkflowRuns: (request: {
+ projectId: string;
+ }) => Promise;
+ getWorkflow: ({ projectId, id, }: {
+ projectId: string;
+ id: string;
+ }) => Promise;
+ getWorkflowRun: ({ projectId, id, }: {
+ projectId: string;
+ id: string;
+ }) => Promise;
+ reRunWorkflow: ({ projectId, runId, }: {
+ projectId: string;
+ runId: string;
+ }) => Promise;
+};
+
+// @public (undocumented)
+export const cloudbuildApiRef: ApiRef;
+
+// @public (undocumented)
+export class CloudbuildClient implements CloudbuildApi {
+ constructor(googleAuthApi: OAuthApi);
+ // (undocumented)
+ getToken(): Promise;
+ // (undocumented)
+ getWorkflow({ projectId, id, }: {
+ projectId: string;
+ id: string;
+ }): Promise;
+ // (undocumented)
+ getWorkflowRun({ projectId, id, }: {
+ projectId: string;
+ id: string;
+ }): Promise;
+ // (undocumented)
+ listWorkflowRuns({ projectId, }: {
+ projectId: string;
+ }): Promise;
+ // (undocumented)
+ reRunWorkflow({ projectId, runId, }: {
+ projectId: string;
+ runId: string;
+ }): Promise;
+}
+
+// @public (undocumented)
+const cloudbuildPlugin: BackstagePlugin<{
+ entityContent: RouteRef;
+}, {}>;
+
+export { cloudbuildPlugin }
+
+export { cloudbuildPlugin as plugin }
+
+// @public (undocumented)
+export const EntityCloudbuildContent: (_props: {
+ entity?: Entity| undefined;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityLatestCloudbuildRunCard: ({ branch, }: {
+ entity?: Entity| undefined;
+ branch: string;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const EntityLatestCloudbuildsForBranchCard: ({ branch, }: {
+ entity?: Entity| undefined;
+ branch: string;
+}) => JSX.Element;
+
+// @public (undocumented)
+export interface FETCHSOURCE {
+ // (undocumented)
+ endTime: string;
+ // (undocumented)
+ startTime: string;
+}
+
+// @public (undocumented)
+const isCloudbuildAvailable: (entity: Entity) => boolean;
+
+export { isCloudbuildAvailable }
+
+export { isCloudbuildAvailable as isPluginApplicableToEntity }
+
+// @public (undocumented)
+export const LatestWorkflowRunCard: ({ branch, }: {
+ entity?: Entity | undefined;
+ branch: string;
+}) => JSX.Element;
+
+// @public (undocumented)
+export const LatestWorkflowsForBranchCard: ({ branch, }: {
+ entity?: Entity | undefined;
+ branch: string;
+}) => JSX.Element;
+
+// @public (undocumented)
+export interface Options {
+ // (undocumented)
+ dynamicSubstitutions: boolean;
+ // (undocumented)
+ logging: string;
+ // (undocumented)
+ machineType: string;
+ // (undocumented)
+ substitutionOption: string;
+}
+
+// @public (undocumented)
+export interface PullTiming {
+ // (undocumented)
+ endTime: string;
+ // (undocumented)
+ startTime: string;
+}
+
+// @public (undocumented)
+export interface ResolvedStorageSource {
+ // (undocumented)
+ bucket: string;
+ // (undocumented)
+ generation: string;
+ // (undocumented)
+ object: string;
+}
+
+// @public (undocumented)
+export interface Results {
+ // (undocumented)
+ buildStepImages: string[];
+ // (undocumented)
+ buildStepOutputs: string[];
+}
+
+// @public (undocumented)
+export const Router: (_props: Props) => JSX.Element;
+
+// @public (undocumented)
+export interface Source {
+ // (undocumented)
+ storageSource: StorageSource;
+}
+
+// @public (undocumented)
+export interface SourceProvenance {
+ // (undocumented)
+ fileHashes: {};
+ // (undocumented)
+ resolvedStorageSource: {};
+}
+
+// @public (undocumented)
+export interface Step {
+ // (undocumented)
+ args: string[];
+ // (undocumented)
+ dir: string;
+ // (undocumented)
+ entrypoint: string;
+ // (undocumented)
+ id: string;
+ // (undocumented)
+ name: string;
+ // (undocumented)
+ pullTiming: PullTiming;
+ // (undocumented)
+ status: string;
+ // (undocumented)
+ timing: Timing;
+ // (undocumented)
+ volumes: Volume[];
+ // (undocumented)
+ waitFor: string[];
+}
+
+// @public (undocumented)
+export interface StorageSource {
+ // (undocumented)
+ bucket: string;
+ // (undocumented)
+ object: string;
+}
+
+// @public (undocumented)
+export interface Substitutions {
+ // (undocumented)
+ BRANCH_NAME: string;
+ // (undocumented)
+ COMMIT_SHA: string;
+ // (undocumented)
+ REPO_NAME: string;
+ // (undocumented)
+ REVISION_ID: string;
+ // (undocumented)
+ SHORT_SHA: string;
+}
+
+// @public (undocumented)
+export interface Timing {
+ // (undocumented)
+ endTime: string;
+ // (undocumented)
+ startTime: string;
+}
+
+// @public (undocumented)
+export interface Timing2 {
+ // (undocumented)
+ BUILD: BUILD;
+ // (undocumented)
+ FETCHSOURCE: FETCHSOURCE;
+}
+
+// @public (undocumented)
+export interface Volume {
+ // (undocumented)
+ name: string;
+ // (undocumented)
+ path: string;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md
new file mode 100644
index 0000000000..f8f1575d0b
--- /dev/null
+++ b/plugins/code-coverage-backend/api-report.md
@@ -0,0 +1,43 @@
+## API Report File for "@backstage/plugin-code-coverage-backend"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { Config } from '@backstage/config';
+import express from 'express';
+import { Logger } from 'winston';
+import { PluginDatabaseManager } from '@backstage/backend-common';
+import { PluginEndpointDiscovery } from '@backstage/backend-common';
+import { UrlReader } from '@backstage/backend-common';
+
+// @public (undocumented)
+export interface CodeCoverageApi {
+ // (undocumented)
+ name: string;
+}
+
+// @public (undocumented)
+export function createRouter(options: RouterOptions): Promise;
+
+// @public (undocumented)
+export const makeRouter: (options: RouterOptions) => Promise;
+
+// @public (undocumented)
+export interface RouterOptions {
+ // (undocumented)
+ config: Config;
+ // (undocumented)
+ database: PluginDatabaseManager;
+ // (undocumented)
+ discovery: PluginEndpointDiscovery;
+ // (undocumented)
+ logger: Logger;
+ // (undocumented)
+ urlReader: UrlReader;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/code-coverage/api-report.md b/plugins/code-coverage/api-report.md
new file mode 100644
index 0000000000..bade5ddb8a
--- /dev/null
+++ b/plugins/code-coverage/api-report.md
@@ -0,0 +1,32 @@
+## API Report File for "@backstage/plugin-code-coverage"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { BackstagePlugin } from '@backstage/core';
+import { Entity } from '@backstage/catalog-model';
+import { RouteRef } from '@backstage/core';
+
+// @public (undocumented)
+export const codeCoveragePlugin: BackstagePlugin<{
+ root: RouteRef;
+}, {}>;
+
+// @public (undocumented)
+export const EntityCodeCoverageContent: () => JSX.Element;
+
+// @public (undocumented)
+const isCodeCoverageAvailable: (entity: Entity) => boolean;
+
+export { isCodeCoverageAvailable }
+
+export { isCodeCoverageAvailable as isPluginApplicableToEntity }
+
+// @public (undocumented)
+export const Router: () => JSX.Element;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/config-schema/api-report.md b/plugins/config-schema/api-report.md
new file mode 100644
index 0000000000..67497be6b6
--- /dev/null
+++ b/plugins/config-schema/api-report.md
@@ -0,0 +1,42 @@
+## API Report File for "@backstage/plugin-config-schema"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+import { BackstagePlugin } from '@backstage/core';
+import { Observable } from '@backstage/core';
+import { RouteRef } from '@backstage/core';
+import { Schema } from 'jsonschema';
+
+// @public (undocumented)
+export interface ConfigSchemaApi {
+ // (undocumented)
+ schema$(): Observable;
+}
+
+// @public (undocumented)
+export const configSchemaApiRef: ApiRef;
+
+// @public (undocumented)
+export const ConfigSchemaPage: () => JSX.Element;
+
+// @public (undocumented)
+export const configSchemaPlugin: BackstagePlugin<{
+ root: RouteRef;
+}, {}>;
+
+// @public
+export class StaticSchemaLoader implements ConfigSchemaApi {
+ constructor({ url }?: {
+ url?: string;
+ });
+ // (undocumented)
+ schema$(): Observable;
+ }
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/cost-insights/api-report.md b/plugins/cost-insights/api-report.md
new file mode 100644
index 0000000000..7476f75cad
--- /dev/null
+++ b/plugins/cost-insights/api-report.md
@@ -0,0 +1,621 @@
+## API Report File for "@backstage/plugin-cost-insights"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+import { BackstagePalette } from '@backstage/theme';
+import { BackstagePlugin } from '@backstage/core';
+import { BackstageTheme } from '@backstage/theme';
+import { ContentRenderer } from 'recharts';
+import { Dispatch } from 'react';
+import { ForwardRefExoticComponent } from 'react';
+import { PaletteOptions } from '@material-ui/core/styles/createPalette';
+import { PropsWithChildren } from 'react';
+import { ReactNode } from 'react';
+import { RechartsFunction } from 'recharts';
+import { RefAttributes } from 'react';
+import { RouteRef } from '@backstage/core';
+import { SetStateAction } from 'react';
+import { TooltipProps } from 'recharts';
+import { TypographyProps } from '@material-ui/core';
+
+// @public
+export type Alert = {
+ title: string | JSX.Element;
+ subtitle: string | JSX.Element;
+ element?: JSX.Element;
+ status?: AlertStatus;
+ url?: string;
+ buttonText?: string;
+ SnoozeForm?: Maybe;
+ AcceptForm?: Maybe;
+ DismissForm?: Maybe;
+ onSnoozed?(options: AlertOptions): Promise;
+ onAccepted?(options: AlertOptions): Promise;
+ onDismissed?(options: AlertOptions): Promise;
+};
+
+// @public (undocumented)
+export interface AlertCost {
+ // (undocumented)
+ aggregation: [number, number];
+ // (undocumented)
+ id: string;
+}
+
+// @public (undocumented)
+export interface AlertDismissFormData {
+ // (undocumented)
+ feedback: Maybe;
+ // (undocumented)
+ other: Maybe;
+ // (undocumented)
+ reason: AlertDismissReason;
+}
+
+// @public (undocumented)
+export interface AlertDismissOption {
+ // (undocumented)
+ label: string;
+ // (undocumented)
+ reason: string;
+}
+
+// @public (undocumented)
+export const AlertDismissOptions: AlertDismissOption[];
+
+// @public (undocumented)
+export enum AlertDismissReason {
+ // (undocumented)
+ Expected = "expected",
+ // (undocumented)
+ Migration = "migration",
+ // (undocumented)
+ NotApplicable = "not-applicable",
+ // (undocumented)
+ Other = "other",
+ // (undocumented)
+ Resolved = "resolved",
+ // (undocumented)
+ Seasonal = "seasonal"
+}
+
+// @public (undocumented)
+export type AlertForm = ForwardRefExoticComponent & RefAttributes>;
+
+// @public (undocumented)
+export type AlertFormProps = {
+ alert: A;
+ onSubmit: (data: FormData) => void;
+ disableSubmit: (isDisabled: boolean) => void;
+};
+
+// @public (undocumented)
+export interface AlertOptions {
+ // (undocumented)
+ data: T;
+ // (undocumented)
+ group: string;
+}
+
+// @public
+export interface AlertSnoozeFormData {
+ // (undocumented)
+ intervals: string;
+}
+
+// @public (undocumented)
+export type AlertSnoozeOption = {
+ label: string;
+ duration: Duration;
+};
+
+// @public (undocumented)
+export const AlertSnoozeOptions: AlertSnoozeOption[];
+
+// @public (undocumented)
+export enum AlertStatus {
+ // (undocumented)
+ Accepted = "accepted",
+ // (undocumented)
+ Dismissed = "dismissed",
+ // (undocumented)
+ Snoozed = "snoozed"
+}
+
+// @public (undocumented)
+export const BarChart: ({ resources, responsive, displayAmount, options, tooltip, onClick, onMouseMove, }: BarChartProps) => JSX.Element;
+
+// @public
+export interface BarChartData extends BarChartOptions {
+}
+
+// @public (undocumented)
+export const BarChartLegend: ({ costStart, costEnd, options, children, }: PropsWithChildren) => JSX.Element;
+
+// @public (undocumented)
+export type BarChartLegendOptions = {
+ previousName: string;
+ previousFill: string;
+ currentName: string;
+ currentFill: string;
+ hideMarker?: boolean;
+};
+
+// @public (undocumented)
+export type BarChartLegendProps = {
+ costStart: number;
+ costEnd: number;
+ options?: Partial;
+};
+
+// @public (undocumented)
+export interface BarChartOptions {
+ // (undocumented)
+ currentFill: string;
+ // (undocumented)
+ currentName: string;
+ // (undocumented)
+ previousFill: string;
+ // (undocumented)
+ previousName: string;
+}
+
+// @public (undocumented)
+export type BarChartProps = {
+ resources: ResourceData[];
+ responsive?: boolean;
+ displayAmount?: number;
+ options?: Partial;
+ tooltip?: ContentRenderer;
+ onClick?: RechartsFunction;
+ onMouseMove?: RechartsFunction;
+};
+
+// @public (undocumented)
+export const BarChartTooltip: ({ title, content, subtitle, topRight, actions, children, }: PropsWithChildren) => JSX.Element;
+
+// @public (undocumented)
+export const BarChartTooltipItem: ({ item }: BarChartTooltipItemProps) => JSX.Element;
+
+// @public (undocumented)
+export type BarChartTooltipItemProps = {
+ item: TooltipItem;
+};
+
+// @public (undocumented)
+export type BarChartTooltipProps = {
+ title: string;
+ content?: ReactNode | string;
+ subtitle?: ReactNode;
+ topRight?: ReactNode;
+ actions?: ReactNode;
+};
+
+// @public (undocumented)
+export interface ChangeStatistic {
+ // (undocumented)
+ amount: number;
+ // (undocumented)
+ ratio?: number;
+}
+
+// @public (undocumented)
+export enum ChangeThreshold {
+ // (undocumented)
+ lower = -0.05,
+ // (undocumented)
+ upper = 0.05
+}
+
+// @public (undocumented)
+export type ChartData = {
+ date: number;
+ trend: number;
+ dailyCost: number;
+ [key: string]: number;
+};
+
+// @public (undocumented)
+export interface Cost {
+ // (undocumented)
+ aggregation: DateAggregation[];
+ // (undocumented)
+ change?: ChangeStatistic;
+ // (undocumented)
+ groupedCosts?: Record;
+ // (undocumented)
+ id: string;
+ // (undocumented)
+ trendline?: Trendline;
+}
+
+// @public (undocumented)
+export const CostGrowth: ({ change, duration }: CostGrowthProps) => JSX.Element;
+
+// @public (undocumented)
+export const CostGrowthIndicator: ({ change, formatter, className, ...props }: CostGrowthIndicatorProps) => JSX.Element;
+
+// @public (undocumented)
+export type CostGrowthIndicatorProps = TypographyProps & {
+ change: ChangeStatistic;
+ formatter?: (change: ChangeStatistic) => Maybe;
+};
+
+// @public (undocumented)
+export type CostGrowthProps = {
+ change: ChangeStatistic;
+ duration: Duration;
+};
+
+// @public (undocumented)
+export type CostInsightsApi = {
+ getLastCompleteBillingDate(): Promise;
+ getUserGroups(userId: string): Promise;
+ getGroupProjects(group: string): Promise;
+ getGroupDailyCost(group: string, intervals: string): Promise;
+ getProjectDailyCost(project: string, intervals: string): Promise;
+ getDailyMetricData(metric: string, intervals: string): Promise;
+ getProductInsights(options: ProductInsightsOptions): Promise;
+ getAlerts(group: string): Promise;
+};
+
+// @public (undocumented)
+export const costInsightsApiRef: ApiRef;
+
+// @public (undocumented)
+export const CostInsightsLabelDataflowInstructionsPage: () => JSX.Element;
+
+// @public (undocumented)
+export const CostInsightsPage: () => JSX.Element;
+
+// @public (undocumented)
+export type CostInsightsPalette = BackstagePalette & CostInsightsPaletteAdditions;
+
+// @public (undocumented)
+export type CostInsightsPaletteOptions = PaletteOptions & CostInsightsPaletteAdditions;
+
+// @public (undocumented)
+const costInsightsPlugin: BackstagePlugin<{
+ root: RouteRef;
+ growthAlerts: RouteRef;
+ unlabeledDataflowAlerts: RouteRef;
+}, {}>;
+
+export { costInsightsPlugin }
+
+export { costInsightsPlugin as plugin }
+
+// @public (undocumented)
+export const CostInsightsProjectGrowthInstructionsPage: () => JSX.Element;
+
+// @public (undocumented)
+export interface CostInsightsTheme extends BackstageTheme {
+ // (undocumented)
+ palette: CostInsightsPalette;
+}
+
+// @public (undocumented)
+export interface CostInsightsThemeOptions extends PaletteOptions {
+ // (undocumented)
+ palette: CostInsightsPaletteOptions;
+}
+
+// @public (undocumented)
+export interface Currency {
+ // (undocumented)
+ kind: string | null;
+ // (undocumented)
+ label: string;
+ // (undocumented)
+ prefix?: string;
+ // (undocumented)
+ rate?: number;
+ // (undocumented)
+ unit: string;
+}
+
+// @public (undocumented)
+export enum CurrencyType {
+ // (undocumented)
+ Beers = "BEERS",
+ // (undocumented)
+ CarbonOffsetTons = "CARBON_OFFSET_TONS",
+ // (undocumented)
+ IceCream = "PINTS_OF_ICE_CREAM",
+ // (undocumented)
+ USD = "USD"
+}
+
+// @public (undocumented)
+export enum DataKey {
+ // (undocumented)
+ Current = "current",
+ // (undocumented)
+ Name = "name",
+ // (undocumented)
+ Previous = "previous"
+}
+
+// @public (undocumented)
+export type DateAggregation = {
+ date: string;
+ amount: number;
+};
+
+// @public (undocumented)
+export const DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
+
+// @public
+export enum Duration {
+ // (undocumented)
+ P30D = "P30D",
+ // (undocumented)
+ P3M = "P3M",
+ // (undocumented)
+ P7D = "P7D",
+ // (undocumented)
+ P90D = "P90D"
+}
+
+// @public (undocumented)
+export const EngineerThreshold = 0.5;
+
+// @public (undocumented)
+export interface Entity {
+ // (undocumented)
+ aggregation: [number, number];
+ // (undocumented)
+ change: ChangeStatistic;
+ // (undocumented)
+ entities: Record;
+ // (undocumented)
+ id: Maybe;
+}
+
+// @public (undocumented)
+export class ExampleCostInsightsClient implements CostInsightsApi {
+ // (undocumented)
+ getAlerts(group: string): Promise;
+ // (undocumented)
+ getDailyMetricData(metric: string, intervals: string): Promise;
+ // (undocumented)
+ getGroupDailyCost(group: string, intervals: string): Promise;
+ // (undocumented)
+ getGroupProjects(group: string): Promise;
+ // (undocumented)
+ getLastCompleteBillingDate(): Promise;
+ // (undocumented)
+ getProductInsights(options: ProductInsightsOptions): Promise;
+ // (undocumented)
+ getProjectDailyCost(project: string, intervals: string): Promise;
+ // (undocumented)
+ getUserGroups(userId: string): Promise;
+ }
+
+// @public (undocumented)
+export type Group = {
+ id: string;
+};
+
+// @public (undocumented)
+export enum GrowthType {
+ // (undocumented)
+ Excess = 2,
+ // (undocumented)
+ Negligible = 0,
+ // (undocumented)
+ Savings = 1
+}
+
+// @public (undocumented)
+export type Icon = {
+ kind: string;
+ component: JSX.Element;
+};
+
+// @public (undocumented)
+export enum IconType {
+ // (undocumented)
+ Compute = "compute",
+ // (undocumented)
+ Data = "data",
+ // (undocumented)
+ Database = "database",
+ // (undocumented)
+ ML = "ml",
+ // (undocumented)
+ Search = "search",
+ // (undocumented)
+ Storage = "storage"
+}
+
+// @public (undocumented)
+export const LegendItem: ({ title, tooltipText, markerColor, children, }: PropsWithChildren) => JSX.Element;
+
+// @public (undocumented)
+export type LegendItemProps = {
+ title: string;
+ tooltipText?: string;
+ markerColor?: string;
+};
+
+// @public (undocumented)
+export type Loading = Record;
+
+// @public (undocumented)
+export type Maybe = T | null;
+
+// @public (undocumented)
+export type Metric = {
+ kind: string;
+ name: string;
+ default: boolean;
+};
+
+// @public (undocumented)
+export interface MetricData {
+ // (undocumented)
+ aggregation: DateAggregation[];
+ // (undocumented)
+ change: ChangeStatistic;
+ // (undocumented)
+ format: 'number' | 'currency';
+ // (undocumented)
+ id: string;
+}
+
+// @public (undocumented)
+export const MockConfigProvider: ({ children, ...context }: MockConfigProviderProps) => JSX.Element;
+
+// @public (undocumented)
+export const MockCurrencyProvider: ({ children, ...context }: MockCurrencyProviderProps) => JSX.Element;
+
+// @public (undocumented)
+export interface PageFilters {
+ // (undocumented)
+ duration: Duration;
+ // (undocumented)
+ group: Maybe;
+ // (undocumented)
+ metric: string | null;
+ // (undocumented)
+ project: Maybe;
+}
+
+// @public (undocumented)
+export interface Product {
+ // (undocumented)
+ kind: string;
+ // (undocumented)
+ name: string;
+}
+
+// @public (undocumented)
+export type ProductFilters = Array;
+
+// @public (undocumented)
+export type ProductInsightsOptions = {
+ product: string;
+ group: string;
+ intervals: string;
+ project: Maybe;
+};
+
+// @public (undocumented)
+export interface ProductPeriod {
+ // (undocumented)
+ duration: Duration;
+ // (undocumented)
+ productType: string;
+}
+
+// @public (undocumented)
+export interface Project {
+ // (undocumented)
+ id: string;
+ // (undocumented)
+ name?: string;
+}
+
+// @public
+export class ProjectGrowthAlert implements Alert {
+ constructor(data: ProjectGrowthData);
+ // (undocumented)
+ data: ProjectGrowthData;
+ // (undocumented)
+ get element(): JSX.Element;
+ // (undocumented)
+ get subtitle(): string;
+ // (undocumented)
+ get title(): string;
+ // (undocumented)
+ get url(): string;
+}
+
+// @public (undocumented)
+export interface ProjectGrowthData {
+ // (undocumented)
+ aggregation: [number, number];
+ // (undocumented)
+ change: ChangeStatistic;
+ // (undocumented)
+ periodEnd: string;
+ // (undocumented)
+ periodStart: string;
+ // (undocumented)
+ products: Array;
+ // (undocumented)
+ project: string;
+}
+
+// @public (undocumented)
+export interface ResourceData {
+ // (undocumented)
+ current: number;
+ // (undocumented)
+ name: Maybe;
+ // (undocumented)
+ previous: number;
+}
+
+// @public (undocumented)
+export type TooltipItem = {
+ fill: string;
+ label: string;
+ value: string;
+};
+
+// @public (undocumented)
+export type Trendline = {
+ slope: number;
+ intercept: number;
+};
+
+// @public
+export class UnlabeledDataflowAlert implements Alert {
+ constructor(data: UnlabeledDataflowData);
+ // (undocumented)
+ data: UnlabeledDataflowData;
+ // (undocumented)
+ get element(): JSX.Element;
+ // (undocumented)
+ status?: AlertStatus;
+ // (undocumented)
+ get subtitle(): string;
+ // (undocumented)
+ get title(): string;
+ // (undocumented)
+ get url(): string;
+}
+
+// @public (undocumented)
+export interface UnlabeledDataflowAlertProject {
+ // (undocumented)
+ id: string;
+ // (undocumented)
+ labeledCost: number;
+ // (undocumented)
+ unlabeledCost: number;
+}
+
+// @public (undocumented)
+export interface UnlabeledDataflowData {
+ // (undocumented)
+ labeledCost: number;
+ // (undocumented)
+ periodEnd: string;
+ // (undocumented)
+ periodStart: string;
+ // (undocumented)
+ projects: Array;
+ // (undocumented)
+ unlabeledCost: number;
+}
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/explore-react/api-report.md b/plugins/explore-react/api-report.md
new file mode 100644
index 0000000000..aaf272a06a
--- /dev/null
+++ b/plugins/explore-react/api-report.md
@@ -0,0 +1,31 @@
+## API Report File for "@backstage/plugin-explore-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { ApiRef } from '@backstage/core';
+
+// @public (undocumented)
+export type ExploreTool = {
+ title: string;
+ description?: string;
+ url: string;
+ image: string;
+ tags?: string[];
+ lifecycle?: string;
+};
+
+// @public (undocumented)
+export interface ExploreToolsConfig {
+ // (undocumented)
+ getTools: () => Promise;
+}
+
+// @public (undocumented)
+export const exploreToolsConfigRef: ApiRef;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/explore/README.md b/plugins/explore/README.md
index fea1333e34..8e1cbaed02 100644
--- a/plugins/explore/README.md
+++ b/plugins/explore/README.md
@@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers';
```
+
+## Customization
+
+Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
+
+```tsx
+import {
+ DomainExplorerContent,
+ ExploreLayout,
+} from '@backstage/plugin-explore';
+import React from 'react';
+import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
+
+export const ExplorePage = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const explorePage = ;
+```
+
+Now register the new explore page in `packages/app/src/App.tsx`.
+
+```diff
++ import { explorePage } from './components/explore/ExplorePage';
+
+const routes = (
+
+- } />
++ }>
++ {explorePage}
++
+
+);
+```
diff --git a/plugins/explore/api-report.md b/plugins/explore/api-report.md
new file mode 100644
index 0000000000..590af53de8
--- /dev/null
+++ b/plugins/explore/api-report.md
@@ -0,0 +1,38 @@
+## API Report File for "@backstage/plugin-explore"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { BackstagePlugin } from '@backstage/core';
+import { ExternalRouteRef } from '@backstage/core';
+import { RouteRef } from '@backstage/core';
+
+// @public (undocumented)
+export const catalogEntityRouteRef: ExternalRouteRef<{
+ name: string;
+ kind: string;
+ namespace: string;
+}, false>;
+
+// @public (undocumented)
+export const ExplorePage: () => JSX.Element;
+
+// @public (undocumented)
+export const explorePlugin: BackstagePlugin<{
+ explore: RouteRef;
+}, {
+ catalogEntity: ExternalRouteRef<{
+ name: string;
+ kind: string;
+ namespace: string;
+ }, false>;
+}>;
+
+// @public (undocumented)
+export const exploreRouteRef: RouteRef;
+
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/plugins/explore/package.json b/plugins/explore/package.json
index 746d8fc1ce..156a3f621e 100644
--- a/plugins/explore/package.json
+++ b/plugins/explore/package.json
@@ -38,9 +38,11 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
+ "@types/react": "^16.9",
"classnames": "^2.2.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
+ "react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx
new file mode 100644
index 0000000000..8f69ea3527
--- /dev/null
+++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { ApiProvider, ApiRegistry } from '@backstage/core';
+import { catalogApiRef } from '@backstage/plugin-catalog-react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { waitFor, getByText } from '@testing-library/react';
+import React from 'react';
+import { DefaultExplorePage } from './DefaultExplorePage';
+
+describe('', () => {
+ const catalogApi: jest.Mocked = {
+ addLocation: jest.fn(_a => new Promise(() => {})),
+ getEntities: jest.fn(),
+ getOriginLocationByEntity: jest.fn(),
+ getLocationByEntity: jest.fn(),
+ getLocationById: jest.fn(),
+ removeLocationById: jest.fn(),
+ removeEntityByUid: jest.fn(),
+ getEntityByName: jest.fn(),
+ };
+
+ const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders the default explore page', async () => {
+ catalogApi.getEntities.mockResolvedValue({ items: [] });
+
+ const { getAllByRole } = await renderInTestApp(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ const elements = getAllByRole('tab');
+ expect(elements.length).toBe(3);
+ expect(getByText(elements[0], 'Domains')).toBeInTheDocument();
+ expect(getByText(elements[1], 'Groups')).toBeInTheDocument();
+ expect(getByText(elements[2], 'Tools')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx
new file mode 100644
index 0000000000..abc73dca31
--- /dev/null
+++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 { configApiRef, useApi } from '@backstage/core';
+import { DomainExplorerContent } from '../DomainExplorerContent';
+import { ExploreLayout } from '../ExploreLayout';
+import { GroupsExplorerContent } from '../GroupsExplorerContent';
+import { ToolExplorerContent } from '../ToolExplorerContent';
+
+export const DefaultExplorePage = () => {
+ const configApi = useApi(configApiRef);
+ const organizationName =
+ configApi.getOptionalString('organization.name') ?? 'Backstage';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/explore/src/components/DefaultExplorePage/index.ts b/plugins/explore/src/components/DefaultExplorePage/index.ts
new file mode 100644
index 0000000000..b4df272266
--- /dev/null
+++ b/plugins/explore/src/components/DefaultExplorePage/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 Spotify AB
+ *
+ * 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 { DefaultExplorePage } from './DefaultExplorePage';
diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx
index 1784587a95..1a93463862 100644
--- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx
+++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx
@@ -41,6 +41,12 @@ describe('', () => {
);
+ const mountedRoutes = {
+ mountedRoutes: {
+ '/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
+ },
+ };
+
beforeEach(() => {
jest.resetAllMocks();
});
@@ -74,11 +80,7 @@ describe('', () => {
,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
- },
- },
+ mountedRoutes,
);
await waitFor(() => {
@@ -87,6 +89,19 @@ describe('', () => {
});
});
+ it('renders a custom title', async () => {
+ catalogApi.getEntities.mockResolvedValue({ items: [] });
+
+ const { getByText } = await renderInTestApp(
+
+
+ ,
+ mountedRoutes,
+ );
+
+ await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument());
+ });
+
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
@@ -94,11 +109,7 @@ describe('', () => {
,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
- },
- },
+ mountedRoutes,
);
await waitFor(() =>
@@ -114,11 +125,7 @@ describe('', () => {
,
- {
- mountedRoutes: {
- '/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
- },
- },
+ mountedRoutes,
);
await waitFor(() =>
diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
index a12811e486..8217413842 100644
--- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
+++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx
@@ -79,10 +79,16 @@ const Body = () => {
);
};
-export const DomainExplorerContent = () => {
+type DomainExplorerContentProps = {
+ title?: string;
+};
+
+export const DomainExplorerContent = ({
+ title,
+}: DomainExplorerContentProps) => {
return (
-
+ Discover the domains in your ecosystem.
diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx
new file mode 100644
index 0000000000..1021ff25d0
--- /dev/null
+++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { waitFor } from '@testing-library/react';
+import React from 'react';
+import { ExploreLayout } from './ExploreLayout';
+
+describe('', () => {
+ const Wrapper = ({ children }: { children?: React.ReactNode }) => (
+ <>{children}>
+ );
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders an explore tabbed layout page with defaults', async () => {
+ const { getByText } = await renderInTestApp(
+
+
+
+