Merge branch 'master' of github.com:spotify/backstage into shmidt-i/followup-register-flow
This commit is contained in:
@@ -5,6 +5,8 @@ FROM nginx:mainline
|
||||
|
||||
# The safest way to build this image is to use `yarn docker-build`
|
||||
|
||||
RUN apt-get update && apt-get -y install jq && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY packages/app/dist /usr/share/nginx/html
|
||||
COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template
|
||||
COPY docker/run.sh /usr/local/bin/run.sh
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Run nginx as root
|
||||
sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf
|
||||
|
||||
# Write selected env vars to nginx config
|
||||
envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Inject runtime config into the client
|
||||
function inject_config() {
|
||||
# Read runtime config from env in the same way as the @backstage/config-loader package
|
||||
local config
|
||||
config="$(jq -n 'env |
|
||||
with_entries(select(.key | startswith("APP_CONFIG_")) | .key |= sub("APP_CONFIG_"; "")) |
|
||||
to_entries |
|
||||
reduce .[] as $item (
|
||||
{}; setpath($item.key | split("_"); $item.value | fromjson)
|
||||
)')"
|
||||
|
||||
>&2 echo "Runtime app config: $config"
|
||||
|
||||
local main_js
|
||||
main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)"
|
||||
echo "Writing runtime config to ${main_js}"
|
||||
|
||||
# escape ' and " twice, for both sed and json
|
||||
local config_escaped_1
|
||||
config_escaped_1="$(echo "$config" | jq -cM . | sed -e 's/[\\"\x27]/\\&/g')" # \x27 = '
|
||||
# escape / and & for sed
|
||||
local config_escaped_2
|
||||
config_escaped_2="$(echo "$config_escaped_1" | sed -e 's/[\/&]/\\&/g')"
|
||||
|
||||
# Replace __APP_INJECTED_RUNTIME_CONFIG__ in the main chunk with the runtime config
|
||||
sed -e "s/__APP_INJECTED_RUNTIME_CONFIG__/$config_escaped_2/" -i "$main_js"
|
||||
}
|
||||
|
||||
inject_config
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
|
||||
+143
-28
@@ -1,52 +1,167 @@
|
||||
# FAQ
|
||||
|
||||
## Do I have to write plugins in TypeScript?
|
||||
## Product FAQ:
|
||||
|
||||
### Can we call Backstage something different? So that it fits our company better?
|
||||
|
||||
Yes, Backstage is just a platform for building your own developer portal. We happen to call our internal version Backstage, as well, as a reference to our music roots. You can call your version whatever suits your team, company, or brand.
|
||||
|
||||
### Is Backstage a monitoring platform?
|
||||
|
||||
No, but it can be! Backstage is designed to be a developer portal for all your infrastructure tooling, services, and documentation. So, it's not a monitoring platform — but that doesn't mean you can't integrate a monitoring tool into Backstage by writing [a plugin](https://github.com/spotify/faq#what-is-a-plugin-in-backstage).
|
||||
|
||||
### How is Backstage licensed?
|
||||
|
||||
Backstage was released as free and open software by Spotify and is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
|
||||
|
||||
### Why did we open source Backstage?
|
||||
|
||||
We hope to see Backstage become the infrastructure standard everywhere. When we saw how much Backstage improved developer experience and productivity internally, we wanted to share those gains. After all, if Backstage can create order in an engineering environment as open and diverse as ours, then we're pretty sure it can create order (and boost productivity) anywhere. To learn more, read our blog post, "[What the heck is Backstage anyway?](https://backstage.io/blog/2020/03/18/what-is-backstage)"
|
||||
|
||||
### Will Spotify's internal plugins be open sourced, too?
|
||||
|
||||
Yes, we've already started releasing open source versions of some of the plugins we use here, and we'll continue to do so. [Plugins](https://github.com/spotify/faq#what-is-a-plugin-in-backstage) are the building blocks of functionality in Backstage. We have over 120 plugins inside Spotify — many of those are specialized for our use, so will remain internal and proprietary to us. But we estimate that about a third of our existing plugins make good open source candidates. (And we'll probably end up writing some brand new ones, too.)
|
||||
|
||||
|
||||
### What's the roadmap for Backstage?
|
||||
|
||||
|
||||
We envision three phases, which you can learn about in [our project roadmap](https://github.com/spotify/backstage#project-roadmap). Even though the open source version of Backstage is relatively new compared to our internal version, we have already begun work on various aspects of all three phases. Looking at the [milestones for active issues](https://github.com/spotify/backstage/milestones) will also give you a sense of our progress.
|
||||
|
||||
### My company doesn't have thousands of developers or services. Is Backstage overkill?
|
||||
|
||||
Not at all! A core reason to adopt Backstage is to standardize how software is built at your company. It's easier to decide on those standards as a small company, and grows in importance as the company grows. Backstage sets a foundation, and an early investment in your infrastructure becomes even more valuable as you grow.
|
||||
|
||||
### Our company has a strong design language system/brand that we want to incorporate. Does Backstage support this?
|
||||
|
||||
Yes! The Backstage UI is built using Material-UI. With the theming capabilities of Material-UI, you are able to adapt the interface to your brand guidelines.
|
||||
|
||||
## Technical FAQ:
|
||||
|
||||
### Why Material-UI?
|
||||
|
||||
The short answer is that's what we've been using in Backstage internally.
|
||||
|
||||
The original decision was based on Google's Material Design being a thorough, well thought out and complete design system, with many mature and powerful libraries implemented in both the system itself and auxiliary components that we knew that we would like to use.
|
||||
|
||||
It strikes a good balance between power, customizability, and ease of use. A core focus of Backstage is to make plugin developers productive with as few hurdles as possible. Material-UI lets plugin makers get going easily with both well-known tech and a large flora of components.
|
||||
|
||||
|
||||
### What technology does Backstage use?
|
||||
|
||||
|
||||
The code base is a large-scale React application that uses TypeScript. For [Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use Node.js and GraphQL.
|
||||
|
||||
|
||||
### What is the end-to-end user flow? The happy path story.
|
||||
|
||||
|
||||
There are three main user profiles for Backstage: the integrator, the contributor, and the software engineer.
|
||||
|
||||
The **integrator** hosts the Backstage app and configures which plugins are available to use in the app.
|
||||
|
||||
The **contributor** adds functionality to the app by writing plugins.
|
||||
|
||||
The **software engineer** uses the app's functionality and interacts with its plugins.
|
||||
|
||||
|
||||
### What is a "plugin" in Backstage?
|
||||
|
||||
|
||||
Plugins are what provide the feature functionality in Backstage. They are used to integrate different systems into Backstage's frontend, so that the developer gets a consistent UX, no matter what tool or service is being accessed on the other side.
|
||||
|
||||
Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform APIs and reusable UI components. Plugins can fetch data either from the backend or an API exposed through the proxy.
|
||||
|
||||
Learn more about [the different components](https://github.com/spotify/backstage#overview) that make up Backstage.
|
||||
|
||||
|
||||
### Do I have to write plugins in TypeScript?
|
||||
|
||||
|
||||
No, you can use JavaScript if you prefer.
|
||||
|
||||
We want to keep the Backstage core APIs in TypeScript, but aren't forcing it on individual plugins.
|
||||
|
||||
|
||||
We want to keep the Backstage core APIs in TypeScript, but don't force it on individual plugins.
|
||||
### How do I find out if a plugin already exists?
|
||||
|
||||
## Q: Why Material-UI?
|
||||
|
||||
Before you write a plugin, [search the plugin issues](https://github.com/spotify/backstage/issues?q=is%3Aissue+label%3Aplugin+) to see if it already exists or is in the works. If no one's thought of it yet, great! Open a new issue as [a plugin suggestion](https://github.com/spotify/backstage/issues/new/choose) and describe what your plugin will do. This will help coordinate our contributors' efforts and avoid duplicating existing functionality.
|
||||
|
||||
In the future, we will create [a plugin gallery](https://github.com/spotify/backstage/issues/260) where people can browse and search for all available plugins.
|
||||
|
||||
|
||||
The short answer is that it's what we've been using in Backstage internally.
|
||||
### Which plugin is used the most at Spotify?
|
||||
|
||||
The original choice is based on Google's material design being a thorough and well
|
||||
thought out full design system, with many mature and powerful libraries implementing
|
||||
both the system itself and auxiliary components that we knew that we would like to use.
|
||||
|
||||
By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/faq#will-spotifys-internal-plugins-be-open-sourced-too)" above)
|
||||
|
||||
|
||||
It strikes a good balance between power, customisability, and ease of use. Since a core
|
||||
focus of Backstage is to make plugin developers productive with as few hurdles as
|
||||
possible, material-ui lets plugin makers both get going easily with well-known tech
|
||||
and a large flora of components.
|
||||
### Are you planning to have plugins baked into the repo? Or should they be developed in separate repos?
|
||||
|
||||
## Q: Are you planning on having plugins cooked into the repo or should they be developed in separate repos?
|
||||
|
||||
Contributors can add open source plugins to the plugins directory in [this monorepo](https://github.com/spotify/backstage). Integrators can then configure which open source plugins are available to use in their instance of the app. Open source plugins are downloaded as npm packages published in the open source repository.
|
||||
|
||||
While we encourage using the open source model, we know there are cases where contributors might want to experiment internally or keep their plugins closed source. Contributors writing closed source plugins should develop them in the plugins directory in their own Backstage repository. Integrators also configure closed source plugins locally from the monorepo.
|
||||
|
||||
|
||||
Additional open sourced plugins would be added to the `plugins` directory in this monorepo.
|
||||
### Any plans for integrating with other repository managers, such as GitLab or Bitbucket?
|
||||
|
||||
While we encourage using the open source model, integrators that want to experiment with
|
||||
Backstage internally may also choose to develop closed source plugins in a manner that suits
|
||||
them best, for example in their respective Backstage source repository.
|
||||
|
||||
We chose GitHub because it is the tool that we are most familiar with, so that will naturally lead to integrations for GitHub being developed at an early stage.
|
||||
|
||||
Hosting this project on GitHub does not exclude integrations with alternatives, such as GitLab or Bitbucket. We believe that in time there will be plugins that will provide functionality for these tools as well. Hopefully, contributed by the community!
|
||||
|
||||
Also note, implementations of Backstage can be hosted wherever you feel suits your needs best.
|
||||
|
||||
|
||||
## Q: Any plans for integrating with other repository managers such as Gitlab or Bitbucket?
|
||||
### Who maintains Backstage?
|
||||
|
||||
We chose Github by the fact that it is the tool that we are most familiar with and that will naturally
|
||||
lead to integrations for Github specifically being developed in an early stage.
|
||||
|
||||
Spotify will maintain the open source core, but we envision different parts of the project being maintained by various companies and contributors. We also envision a large, diverse ecosystem of open source plugins, which would be maintained by their original authors/contributors or by the community.
|
||||
|
||||
When it comes to [deployment](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md), the system integrator (typically, the infrastructure team in your organization) maintains Backstage in your own environment.
|
||||
|
||||
|
||||
Hosting this project on Github does not exclude integrations with other alternatives such as Gitlab or
|
||||
Bitbucket. We believe that in time there will be plugins that will provide functionality for these tools
|
||||
as well. Hopefully contributed by the community.
|
||||
### Does Spotify provide a managed version of Backstage?
|
||||
|
||||
And note that implementations of Backstage can be hosted wherever you feel suits your needs best.
|
||||
|
||||
No, this is not a service offering. We build the piece of software, and someone in your infrastructure team is responsible for [deploying](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md) and maintaining it.
|
||||
|
||||
|
||||
## Q: Can Backstage by used for other things than developer portals?
|
||||
### How secure is Backstage?
|
||||
|
||||
Yes.
|
||||
|
||||
We take security seriously. When it comes to packages and code we scan our repositories periodically and update our packages to the latest versions. When it comes to deployment of Backstage within an organisation it depends on the deployment and security setup in your organisation. Reach out to us on [Discord](https://discord.gg/MUpMjP2) if you have specific queries.
|
||||
|
||||
The core frontend framework could be used for building any large-scale web application where multiple teams are building separate parts of the app, but you want the overall experience to be consistent.
|
||||
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
|
||||
|
||||
|
||||
### Does Backstage collect any information that is shared with Spotify?
|
||||
|
||||
|
||||
No. Backstage does not collect any telemetry from any third party using the platform. Spotify, and the open source community, does have access to [GitHub Insights](https://github.com/features/insights), which contains information such as contributors, commits, traffic, and dependencies.
|
||||
|
||||
Backstage is an open platform, but you are in control of your own data. You control who has access to any data you provide to your version of Backstage and who that data is shared with.
|
||||
|
||||
|
||||
### Can Backstage be used to build something other than a developer portal?
|
||||
|
||||
|
||||
Yes. The core frontend framework could be used for building any large-scale web application where (1) multiple teams are building separate parts of the app, and (2) you want the overall experience to be consistent.
|
||||
|
||||
That being said, in [Phase 2](https://github.com/spotify/backstage#project-roadmap) of the project we will add features that are needed for developer portals and systems for managing software ecosystems. Our ambition will be to keep Backstage modular.
|
||||
|
||||
|
||||
## Q: My company doesn't have thousands of developers. Is Backstage overkill?
|
||||
### How can I get involved?
|
||||
|
||||
Not really. Sure, having something like Backstage gets more important as the number of developers in your company grows. One of the core reasons to adopt Backstage is to help standardise how software is built at your company. Setting guidelines and deciding on standards is easier when your company is smaller.
|
||||
|
||||
Jump right in! Come help us fix some of the [early bugs and first issues](https://github.com/spotify/backstage/labels/good%20first%20issue) or reach [a new milestone](https://github.com/spotify/backstage/milestones). Or write an open source plugin for Backstage, like this [Lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse).
|
||||
|
||||
See all the ways you can [contribute here](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md). We'd love to have you as part of the community.
|
||||
|
||||
|
||||
### Can I join the Backstage team?
|
||||
|
||||
|
||||
If you're interested in being part of the Backstage team, reach out to [fossopportunities@spotify.com](mailto:fossopportunities@spotify.com)
|
||||
|
||||
@@ -7,3 +7,4 @@ Check out <https://backstage.io> or see the table of content below.
|
||||
- [References](reference/README.md)
|
||||
- [Publishing](publishing.md)
|
||||
- [Designing for Backstage](design.md)
|
||||
- [How to add an auth provider](auth/add-auth-provider.md)
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Adding authentication providers
|
||||
|
||||
## Passport
|
||||
|
||||
We chose [Passport](http://www.passportjs.org/) as our authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/).
|
||||
|
||||
## How to add a new strategy provider
|
||||
|
||||
### Quick guide
|
||||
|
||||
[1.](#installing-the-dependencies) Install the passport-js based provider package.
|
||||
|
||||
[2.](#create-implementation) Create a new folder structure for the provider.
|
||||
|
||||
[3.](#adding-an-oauth-based-provider) Implement the provider, extending the suitable framework if needed.
|
||||
|
||||
[4.](#hook-it-up-to-the-backend) Add the provider to the backend.
|
||||
|
||||
### Installing the dependencies:
|
||||
|
||||
```bash
|
||||
cd plugins/auth-backend
|
||||
yarn add passport-provider-a
|
||||
yarn add @types/passport-provider-a
|
||||
```
|
||||
|
||||
### Create implementation
|
||||
|
||||
Make a new folder with the name of the provider following the below file structure:
|
||||
|
||||
```bash
|
||||
plugins/auth-backend/src/providers/providerA
|
||||
├── index.ts
|
||||
└── provider.ts
|
||||
```
|
||||
|
||||
**`plugins/auth-backend/src/providers/providerA/provider.ts`** defines the provider class which implements a handler for the chosen framework.
|
||||
|
||||
#### Adding an OAuth based provider
|
||||
|
||||
If we're adding an `OAuth` based provider we would implement the [OAuthProviderHandlers](#OAuthProviderHandlers) interface.
|
||||
|
||||
The provider class takes the provider's configuration as a class parameter. It also imports the `Strategy` from the passport package.
|
||||
|
||||
```ts
|
||||
import { Strategy as ProviderAStrategy } from 'passport-provider-a';
|
||||
|
||||
export class ProviderAAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
private readonly _strategy: ProviderAStrategy;
|
||||
|
||||
constructor(providerConfig: AuthProviderConfig) {
|
||||
this.providerConfig = providerConfig;
|
||||
this._strategy = new ProviderAStrategy(
|
||||
{ ...providerConfig.options },
|
||||
verifyFunction, // See the "Verify Callback" section
|
||||
);
|
||||
}
|
||||
|
||||
async start() {}
|
||||
async handler() {}
|
||||
}
|
||||
```
|
||||
|
||||
#### Adding an non-OAuth based provider
|
||||
|
||||
_**Note**: We have prioritized OAuth-based providers and non-OAuth providers should be considered experimental._
|
||||
|
||||
An non-`OAuth` based provider could implement [AuthProviderRouteHandlers](#AuthProviderRouteHandlers) instead.
|
||||
|
||||
```ts
|
||||
export class ProviderAAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
private readonly _strategy: ProviderAStrategy;
|
||||
|
||||
constructor(providerConfig: AuthProviderConfig) {
|
||||
this.providerConfig = providerConfig;
|
||||
this._strategy = new ProviderAStrategy(
|
||||
{ ...providerConfig.options },
|
||||
verifyFunction, // See the "Verify Callback" section
|
||||
);
|
||||
}
|
||||
|
||||
async start() {}
|
||||
async frameHandler() {}
|
||||
async logout() {}
|
||||
async refresh() {} // If supported
|
||||
}
|
||||
```
|
||||
|
||||
#### Create method
|
||||
|
||||
Each provider exports a create method that creates the provider instance, optionally extending a supported authorization framework. This method exists to allow for flexibility if additional frameworks are supported in the future.
|
||||
|
||||
Implementing OAuth by returning an instance of `OAuthProvider` based of the provider's class:
|
||||
|
||||
```ts
|
||||
export function createProviderAProvider(config: AuthProviderConfig) {
|
||||
const provider = new ProviderAAuthProvider(config);
|
||||
const oauthProvider = new OAuthProvider(provider, config.provider, true);
|
||||
return oauthProvider;
|
||||
}
|
||||
```
|
||||
|
||||
Not extending with OAuth, the main difference here is that the create method is returning a instance of the class without adding the OAuth authorization framework to it.
|
||||
|
||||
```ts
|
||||
export function createProviderAProvider(config: AuthProviderConfig) {
|
||||
return new ProviderAAuthProvider(config);
|
||||
}
|
||||
```
|
||||
|
||||
#### Verify Callback
|
||||
|
||||
> Strategies require what is known as a verify callback. The purpose of a verify callback is to find the user that possesses a set of credentials.
|
||||
> When Passport authenticates a request, it parses the credentials contained in the request. It then invokes the verify callback with those credentials as arguments [...]. If the credentials are valid, the verify callback invokes done to supply Passport with the user that authenticated.
|
||||
>
|
||||
> If the credentials are not valid (for example, if the password is incorrect), done should be invoked with false instead of a user to indicate an authentication failure.
|
||||
>
|
||||
> http://www.passportjs.org/docs/configure/
|
||||
|
||||
**`plugins/auth-backend/src/providers/providerA/index.ts`** is simply re-exporting the create method to be used for hooking the provider up to the backend.
|
||||
|
||||
```ts
|
||||
export { createProviderAProvider } from './provider';
|
||||
```
|
||||
|
||||
### Hook it up to the backend
|
||||
|
||||
**`plugins/auth-backend/src/providers/config.ts`** The provider needs to be configured properly so you need to add it to the list of configured providers, all of which implement [AuthProviderConfig](#AuthProviderConfig):
|
||||
|
||||
```ts
|
||||
export const providers = [
|
||||
{
|
||||
provider: 'providerA', # used as an identifier
|
||||
options: { ... }, # consult the provider documentation for which options you should provide
|
||||
disableRefresh: true # if the provider lacks refresh tokens
|
||||
},
|
||||
```
|
||||
|
||||
**`plugins/auth-backend/src/providers/factories.ts`** When the `auth-backend` starts it sets up routing for all the available providers by calling `createAuthProviderRouter` on each provider. You need to import the create method from the provider and add it to the factory:
|
||||
|
||||
```ts
|
||||
import { createProviderAProvider } from './providerA';
|
||||
const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
providerA: createProviderAProvider,
|
||||
};
|
||||
```
|
||||
|
||||
By doing this `auth-backend` automatically adds these endpoints:
|
||||
|
||||
```ts
|
||||
router.get('/auth/providerA/start');
|
||||
router.get('/auth/providerA/handler/frame');
|
||||
router.post('/auth/providerA/handler/frame');
|
||||
router.post('/auth/providerA/logout');
|
||||
router.get('/auth/providerA/refresh'); // if supported
|
||||
```
|
||||
|
||||
As you can see each endpoint is prefixed with both `/auth` and its provider name.
|
||||
|
||||
### Test the new provider
|
||||
|
||||
You can `curl -i localhost:7000/auth/providerA/start` and which should provide a `302` redirect with a `Location` header. Paste the url from that header into a web browser and you should be able to trigger the authorization flow.
|
||||
|
||||
---
|
||||
|
||||
##### OAuthProviderHandlers
|
||||
|
||||
```ts
|
||||
export interface OAuthProviderHandlers {
|
||||
start(req: express.Request, options: any): Promise<any>;
|
||||
handler(req: express.Request): Promise<any>;
|
||||
refresh?(refreshToken: string, scope: string): Promise<any>;
|
||||
logout?(): Promise<any>;
|
||||
}
|
||||
```
|
||||
|
||||
##### AuthProviderRouteHandlers
|
||||
|
||||
```ts
|
||||
export interface AuthProviderRouteHandlers {
|
||||
start(req: express.Request, res: express.Response): Promise<any>;
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<any>;
|
||||
refresh?(req: express.Request, res: express.Response): Promise<any>;
|
||||
logout(req: express.Request, res: express.Response): Promise<any>;
|
||||
}
|
||||
```
|
||||
|
||||
##### AuthProviderConfig
|
||||
|
||||
```ts
|
||||
export type AuthProviderConfig = {
|
||||
provider: string;
|
||||
options: any;
|
||||
disableRefresh?: boolean;
|
||||
};
|
||||
```
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
"packages": ["packages/*", "plugins/*"],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.1-alpha.6"
|
||||
"version": "0.1.1-alpha.7"
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-circleci": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-explore": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-home-page": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-lighthouse": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-register-component": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-tech-radar": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-welcome": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-explore": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-tech-radar": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-welcome": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"prop-types": "^15.7.2",
|
||||
|
||||
@@ -8,47 +8,38 @@
|
||||
name="description"
|
||||
content="Backstage is an open platform for building developer portals"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="<%= publicPath %>/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link
|
||||
rel="manifest"
|
||||
href="%PUBLIC_URL%/manifest.json"
|
||||
href="<%= publicPath %>/manifest.json"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="<%= publicPath %>/favicon.ico" />
|
||||
<link rel="shortcut icon" href="<%= publicPath %>/favicon.ico" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
href="<%= publicPath %>/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
||||
href="<%= publicPath %>/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
||||
href="<%= publicPath %>/favicon-16x16.png"
|
||||
/>
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="%PUBLIC_URL%/safari-pinned-tab.svg"
|
||||
href="<%= publicPath %>/safari-pinned-tab.svg"
|
||||
color="#5bbad5"
|
||||
/>
|
||||
<style>
|
||||
@@ -56,9 +47,9 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
<title>Backstage</title>
|
||||
<title><%= app.title %></title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<body style="margin: 0;">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import Root from './components/Root';
|
||||
import * as plugins from './plugins';
|
||||
import apis from './apis';
|
||||
@@ -34,11 +33,9 @@ const App: FC<{}> = () => (
|
||||
<AppProvider>
|
||||
<AlertDisplay />
|
||||
<OAuthRequestDialog />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
SidebarUserBadge,
|
||||
SidebarThemeToggle,
|
||||
} from '@backstage/core';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
@@ -56,7 +57,12 @@ const SidebarLogo: FC<{}> = () => {
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Link href="/" underline="none" className={classes.link}>
|
||||
<Link
|
||||
component={NavLink}
|
||||
to="/"
|
||||
underline="none"
|
||||
className={classes.link}
|
||||
>
|
||||
{isOpen ? <LogoFull /> : <LogoIcon />}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"main": "dist",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
@@ -32,7 +32,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/http-errors": "^1.6.3",
|
||||
"@types/morgan": "^1.9.0",
|
||||
|
||||
@@ -16,6 +16,7 @@ To run the example backend, first go to the project root and run
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn tsc
|
||||
yarn build
|
||||
```
|
||||
|
||||
@@ -24,7 +25,7 @@ You should only need to do this once.
|
||||
After that, go to the `packages/backend` directory and run
|
||||
|
||||
```bash
|
||||
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x SENTRY_TOKEN=x yarn start
|
||||
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start
|
||||
```
|
||||
|
||||
Substitute `x` for actual values, or leave them as
|
||||
@@ -55,6 +56,12 @@ to the absolute path of a YAML file on disk, you could consume your own experime
|
||||
The catalog currently runs in-memory only, so feel free to try it out, but it will
|
||||
need to be re-populated on next startup.
|
||||
|
||||
## Authentication
|
||||
|
||||
We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/).
|
||||
|
||||
Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md)
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"main": "dist",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
@@ -17,13 +17,13 @@
|
||||
"migrate:create": "knex migrate:make -x ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-identity-backend": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.6",
|
||||
"@backstage/backend-common": "^0.1.1-alpha.7",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"esm": "^3.2.25",
|
||||
@@ -34,7 +34,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
|
||||
@@ -19,24 +19,17 @@ import {
|
||||
DatabaseEntitiesCatalog,
|
||||
DatabaseLocationsCatalog,
|
||||
DatabaseManager,
|
||||
DescriptorParsers,
|
||||
LocationReaders,
|
||||
IngestionModels,
|
||||
runPeriodically,
|
||||
HigherOrderOperations,
|
||||
LocationReaders,
|
||||
runPeriodically,
|
||||
} from '@backstage/plugin-catalog-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
import { EntityPolicies } from '@backstage/catalog-model';
|
||||
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
database,
|
||||
}: PluginEnvironment) {
|
||||
const ingestionModel = new IngestionModels(
|
||||
new LocationReaders(),
|
||||
new DescriptorParsers(),
|
||||
new EntityPolicies(),
|
||||
);
|
||||
const locationReader = new LocationReaders(logger);
|
||||
|
||||
const db = await DatabaseManager.createDatabase(database, logger);
|
||||
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
|
||||
@@ -44,7 +37,7 @@ export default async function createPlugin({
|
||||
const higherOrderOperation = new HigherOrderOperations(
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
ingestionModel,
|
||||
locationReader,
|
||||
logger,
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"lib": ["es2019"],
|
||||
"lib": ["es2019", "dom"],
|
||||
"types": ["node", "jest"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/catalog-model",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"main:src": "src/index.ts",
|
||||
@@ -8,7 +8,10 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
@@ -23,7 +26,7 @@
|
||||
"yup": "^0.28.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/yup": "^0.28.2",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export type { Location, LocationSpec } from './types';
|
||||
export { locationSchema, locationSpecSchema } from './validation';
|
||||
export { LOCATION_ANNOTATION } from './annotation';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
ignorePatterns: ['templates/**'],
|
||||
rules: {
|
||||
'no-console': 0,
|
||||
|
||||
@@ -21,11 +21,13 @@ const path = require('path');
|
||||
const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src'));
|
||||
|
||||
if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
|
||||
require('../dist');
|
||||
require('..');
|
||||
} else {
|
||||
require('ts-node').register({
|
||||
project: path.resolve(__dirname, '../tsconfig.build.json'),
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'CommonJS',
|
||||
},
|
||||
});
|
||||
|
||||
require('../src');
|
||||
|
||||
@@ -67,6 +67,7 @@ module.exports = {
|
||||
name: '@material-ui/icons',
|
||||
message: "Please import '@material-ui/icons/<Icon>' instead.",
|
||||
},
|
||||
...require('module').builtinModules,
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -16,9 +16,9 @@
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "dist/index.js",
|
||||
"main": "dist/index.cjs.js",
|
||||
"scripts": {
|
||||
"build": "backstage-cli build-cache -- tsc --project tsconfig.build.json",
|
||||
"build": "backstage-cli build --outputs cjs",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"test:e2e": "node e2e-test/cli-e2e-test.js",
|
||||
@@ -29,6 +29,8 @@
|
||||
"backstage-cli": "bin/backstage-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"@backstage/config-loader": "^0.1.1-alpha.7",
|
||||
"@hot-loader/react-dom": "^16.13.0",
|
||||
"@lerna/package-graph": "^3.18.5",
|
||||
"@lerna/project": "^3.18.0",
|
||||
@@ -59,7 +61,7 @@
|
||||
"ora": "^4.0.3",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.0.0",
|
||||
"react-dev-utils": "^10.2.0",
|
||||
"react-dev-utils": "^10.2.1",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
"replace-in-file": "^6.0.0",
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '../../lib/app-config';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { buildBundle } from '../../lib/bundler';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
await buildBundle({
|
||||
entry: 'src/index',
|
||||
statsJsonEnabled: cmd.stats,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadConfig } from '../../lib/app-config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'src/index',
|
||||
checksEnabled: cmd.check,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
@@ -17,18 +17,21 @@
|
||||
import fs from 'fs-extra';
|
||||
import { paths } from '../lib/paths';
|
||||
|
||||
const SKIPPED_KEYS = ['access', 'registry', 'tag'];
|
||||
|
||||
export const pre = async () => {
|
||||
const pkgPath = paths.resolveTarget('package.json');
|
||||
|
||||
const pkg = await fs.readJson(pkgPath);
|
||||
pkg.types = 'dist/index.d.ts';
|
||||
|
||||
for (const key of Object.keys(pkg.publishConfig ?? {})) {
|
||||
if (!SKIPPED_KEYS.includes(key)) {
|
||||
pkg[key] = pkg.publishConfig[key];
|
||||
}
|
||||
}
|
||||
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
|
||||
};
|
||||
|
||||
export const post = async () => {
|
||||
const pkgPath = paths.resolveTarget('package.json');
|
||||
|
||||
const pkg = await fs.readJson(pkgPath);
|
||||
pkg.types = 'src/index.ts';
|
||||
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
|
||||
// postpack is a noop for now, since it's not called anyway
|
||||
};
|
||||
|
||||
@@ -15,14 +15,17 @@
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig } from '@backstage/config-loader';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { loadConfig } from '../../lib/app-config';
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
const appConfigs = await loadConfig();
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'dev/index',
|
||||
checksEnabled: cmd.check,
|
||||
appConfig: await loadConfig(),
|
||||
config: ConfigReader.fromConfigs(appConfigs),
|
||||
appConfigs,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
|
||||
+32
-23
@@ -25,93 +25,101 @@ const main = (argv: string[]) => {
|
||||
program
|
||||
.command('create-app')
|
||||
.description('Creates a new app in a new directory')
|
||||
.action(actionHandler(() => require('./commands/create-app/createApp')));
|
||||
.action(
|
||||
lazyAction(() => import('./commands/create-app/createApp'), 'default'),
|
||||
);
|
||||
|
||||
program
|
||||
.command('app:build')
|
||||
.description('Build an app for a production release')
|
||||
.option('--stats', 'Write bundle stats to output directory')
|
||||
.action(actionHandler(() => require('./commands/app/build')));
|
||||
.action(lazyAction(() => import('./commands/app/build'), 'default'));
|
||||
|
||||
program
|
||||
.command('app:serve')
|
||||
.description('Serve an app for local development')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.action(actionHandler(() => require('./commands/app/serve')));
|
||||
.action(lazyAction(() => import('./commands/app/serve'), 'default'));
|
||||
|
||||
program
|
||||
.command('app:diff')
|
||||
.option('--check', 'Fail if changes are required')
|
||||
.option('--yes', 'Apply all changes')
|
||||
.description('Diff an existing app with the creation template')
|
||||
.action(actionHandler(() => require('./commands/app/diff')));
|
||||
.action(lazyAction(() => import('./commands/app/diff'), 'default'));
|
||||
|
||||
program
|
||||
.command('create-plugin')
|
||||
.description('Creates a new plugin in the current repository')
|
||||
.action(
|
||||
actionHandler(() => require('./commands/create-plugin/createPlugin')),
|
||||
lazyAction(
|
||||
() => import('./commands/create-plugin/createPlugin'),
|
||||
'default',
|
||||
),
|
||||
);
|
||||
|
||||
program
|
||||
.command('remove-plugin')
|
||||
.description('Removes plugin in the current repository')
|
||||
.action(
|
||||
actionHandler(() => require('./commands/remove-plugin/removePlugin')),
|
||||
lazyAction(
|
||||
() => import('./commands/remove-plugin/removePlugin'),
|
||||
'default',
|
||||
),
|
||||
);
|
||||
|
||||
program
|
||||
.command('plugin:build')
|
||||
.description('Build a plugin')
|
||||
.action(actionHandler(() => require('./commands/plugin/build')));
|
||||
.action(lazyAction(() => import('./commands/plugin/build'), 'default'));
|
||||
|
||||
program
|
||||
.command('plugin:serve')
|
||||
.description('Serves the dev/ folder of a plugin')
|
||||
.option('--check', 'Enable type checking and linting')
|
||||
.action(actionHandler(() => require('./commands/plugin/serve')));
|
||||
.action(lazyAction(() => import('./commands/plugin/serve'), 'default'));
|
||||
|
||||
program
|
||||
.command('plugin:diff')
|
||||
.option('--check', 'Fail if changes are required')
|
||||
.option('--yes', 'Apply all changes')
|
||||
.description('Diff an existing plugin with the creation template')
|
||||
.action(actionHandler(() => require('./commands/plugin/diff')));
|
||||
.action(lazyAction(() => import('./commands/plugin/diff'), 'default'));
|
||||
|
||||
program
|
||||
.command('build')
|
||||
.description('Build a package for publishing')
|
||||
.option('--outputs <formats>', 'List of formats to output [types,cjs,esm]')
|
||||
.action(actionHandler(() => require('./commands/build')));
|
||||
.action(lazyAction(() => import('./commands/build'), 'default'));
|
||||
|
||||
program
|
||||
.command('lint')
|
||||
.option('--fix', 'Attempt to automatically fix violations')
|
||||
.description('Lint a package')
|
||||
.action(actionHandler(() => require('./commands/lint')));
|
||||
.action(lazyAction(() => import('./commands/lint'), 'default'));
|
||||
|
||||
program
|
||||
.command('test')
|
||||
.allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args
|
||||
.helpOption(', --backstage-cli-help') // Let Jest handle help
|
||||
.description('Run tests, forwarding args to Jest, defaulting to watch mode')
|
||||
.action(actionHandler(() => require('./commands/testCommand')));
|
||||
.action(lazyAction(() => import('./commands/testCommand'), 'default'));
|
||||
|
||||
program
|
||||
.command('prepack')
|
||||
.description('Prepares a package for packaging before publishing')
|
||||
.action(actionHandler(() => require('./commands/pack').pre));
|
||||
.action(lazyAction(() => import('./commands/pack'), 'pre'));
|
||||
|
||||
program
|
||||
.command('postpack')
|
||||
.description('Restores the changes made by the prepack command')
|
||||
.action(actionHandler(() => require('./commands/pack').post));
|
||||
.action(lazyAction(() => import('./commands/pack'), 'post'));
|
||||
|
||||
program
|
||||
.command('watch-deps')
|
||||
.option('--build', 'Build all dependencies on startup')
|
||||
.description('Watch all dependencies while running another command')
|
||||
.action(actionHandler(() => require('./commands/watch-deps')));
|
||||
.action(lazyAction(() => import('./commands/watch-deps'), 'default'));
|
||||
|
||||
program
|
||||
.command('build-cache')
|
||||
@@ -128,12 +136,12 @@ const main = (argv: string[]) => {
|
||||
'Cache dir',
|
||||
'<repoRoot>/node_modules/.cache/backstage-builds',
|
||||
)
|
||||
.action(actionHandler(() => require('./commands/build-cache')));
|
||||
.action(lazyAction(() => import('./commands/build-cache'), 'default'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete cache directories')
|
||||
.action(actionHandler(() => require('./commands/clean/clean')));
|
||||
.action(lazyAction(() => import('./commands/clean/clean'), 'default'));
|
||||
|
||||
program.on('command:*', () => {
|
||||
console.log();
|
||||
@@ -153,15 +161,16 @@ const main = (argv: string[]) => {
|
||||
};
|
||||
|
||||
// Wraps an action function so that it always exits and handles errors
|
||||
function actionHandler<T extends readonly any[]>(
|
||||
actionRequireFunc:
|
||||
| (() => { default(...args: T): Promise<any> })
|
||||
| (() => (...args: T) => Promise<any>),
|
||||
function lazyAction<T extends readonly any[], Export extends string>(
|
||||
actionRequireFunc: () => Promise<
|
||||
{ [name in Export]: (...args: T) => Promise<any> }
|
||||
>,
|
||||
exportName: Export,
|
||||
): (...args: T) => Promise<never> {
|
||||
return async (...args: T) => {
|
||||
try {
|
||||
const ret = actionRequireFunc();
|
||||
const actionFunc = typeof ret === 'function' ? ret : ret.default;
|
||||
const module = await actionRequireFunc();
|
||||
const actionFunc = module[exportName];
|
||||
await actionFunc(...args);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/*
|
||||
* 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 { AppConfig } from './types';
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import { paths } from '../paths';
|
||||
|
||||
type LoadConfigOptions = {
|
||||
// Config path, defaults to app-config.yaml in project root
|
||||
configPath?: string;
|
||||
};
|
||||
|
||||
export async function loadConfig(
|
||||
options: LoadConfigOptions = {},
|
||||
): Promise<AppConfig[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options;
|
||||
|
||||
try {
|
||||
const configYaml = await fs.readFile(configPath, 'utf8');
|
||||
const config = yaml.parse(configYaml);
|
||||
return [config];
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read static configuration file, ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ export async function buildBundle(options: BuildOptions) {
|
||||
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
|
||||
await fs.emptyDir(paths.targetDist);
|
||||
|
||||
if (paths.targetPublic) {
|
||||
await fs.copy(paths.targetPublic, paths.targetDist, {
|
||||
dereference: true,
|
||||
filter: file => file !== paths.targetHtml,
|
||||
});
|
||||
}
|
||||
|
||||
const { stats } = await build(compiler, isCi).catch(error => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
throw new Error(`Failed to compile.\n${error.message || error}`);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import webpack from 'webpack';
|
||||
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
|
||||
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import { BundlingPaths } from './paths';
|
||||
import { transforms } from './transforms';
|
||||
import { optimization } from './optimization';
|
||||
@@ -33,7 +34,13 @@ export function createConfig(
|
||||
): webpack.Configuration {
|
||||
const { checksEnabled, isDev } = options;
|
||||
|
||||
const { plugins, loaders } = transforms(paths, options);
|
||||
const { plugins, loaders } = transforms(options);
|
||||
|
||||
const baseUrl = options.config.getString('app.baseUrl');
|
||||
if (!baseUrl) {
|
||||
throw new Error('app.baseUrl must be set in config');
|
||||
}
|
||||
const validBaseUrl = new URL(baseUrl, 'https://backstage-app.dev');
|
||||
|
||||
if (checksEnabled) {
|
||||
plugins.push(
|
||||
@@ -53,7 +60,20 @@ export function createConfig(
|
||||
|
||||
plugins.push(
|
||||
new webpack.EnvironmentPlugin({
|
||||
APP_CONFIG: options.appConfig,
|
||||
APP_CONFIG: options.appConfigs,
|
||||
}),
|
||||
);
|
||||
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.targetHtml,
|
||||
templateParameters: {
|
||||
publicPath: validBaseUrl.pathname.replace(/\/$/, ''),
|
||||
app: {
|
||||
title: options.config.getString('app.title'),
|
||||
baseUrl: validBaseUrl.href,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -85,7 +105,7 @@ export function createConfig(
|
||||
},
|
||||
output: {
|
||||
path: paths.targetDist,
|
||||
publicPath: '/',
|
||||
publicPath: validBaseUrl.pathname,
|
||||
filename: isDev ? '[name].js' : '[name].[hash:8].js',
|
||||
chunkFilename: isDev
|
||||
? '[name].chunk.js'
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import fs from 'fs-extra';
|
||||
import { paths } from '../paths';
|
||||
|
||||
export type BundlingPathsOptions = {
|
||||
@@ -28,20 +28,29 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
const resolveTargetModule = (path: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
const filePath = paths.resolveTarget(`${path}.${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
if (fs.pathExistsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
return paths.resolveTarget(`${path}.js`);
|
||||
};
|
||||
|
||||
let targetHtml = paths.resolveTarget(`${entry}.html`);
|
||||
if (!existsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
let targetPublic = undefined;
|
||||
let targetHtml = paths.resolveTarget('public/index.html');
|
||||
|
||||
// Prefer public folder
|
||||
if (fs.pathExistsSync(targetHtml)) {
|
||||
targetPublic = paths.resolveTarget('public');
|
||||
} else {
|
||||
targetHtml = paths.resolveTarget(`${entry}.html`);
|
||||
if (!fs.pathExistsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targetHtml,
|
||||
targetPublic,
|
||||
targetPath: paths.resolveTarget('.'),
|
||||
targetDist: paths.resolveTarget('dist'),
|
||||
targetAssets: paths.resolveTarget('assets'),
|
||||
|
||||
@@ -34,7 +34,6 @@ export async function serveBundle(options: ServeOptions) {
|
||||
}
|
||||
|
||||
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
|
||||
const urls = prepareUrls(protocol, host, port);
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const pkgPath = paths.targetPackageJson;
|
||||
@@ -44,7 +43,9 @@ export async function serveBundle(options: ServeOptions) {
|
||||
|
||||
const server = new WebpackDevServer(compiler, {
|
||||
hot: true,
|
||||
publicPath: '/',
|
||||
contentBase: paths.targetPublic,
|
||||
contentBasePublicPath: config.output?.publicPath,
|
||||
publicPath: config.output?.publicPath,
|
||||
historyApiFallback: true,
|
||||
clientLogLevel: 'warning',
|
||||
stats: 'errors-warnings',
|
||||
@@ -61,6 +62,19 @@ export async function serveBundle(options: ServeOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This signature is available in 10.2.1 but doesn't have types published yet
|
||||
const latestPrepareUrls = prepareUrls as (
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
path?: string,
|
||||
) => ReturnType<typeof prepareUrls>;
|
||||
const urls = latestPrepareUrls(
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
config.output?.publicPath,
|
||||
);
|
||||
openBrowser(urls.localUrlForBrowser);
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -15,20 +15,15 @@
|
||||
*/
|
||||
|
||||
import webpack, { Module, Plugin } from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { BundlingOptions } from './types';
|
||||
import { BundlingPaths } from './paths';
|
||||
|
||||
type Transforms = {
|
||||
loaders: Module['rules'];
|
||||
plugins: Plugin[];
|
||||
};
|
||||
|
||||
export const transforms = (
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
): Transforms => {
|
||||
export const transforms = (options: BundlingOptions): Transforms => {
|
||||
const { isDev } = options;
|
||||
|
||||
const loaders = [
|
||||
@@ -80,12 +75,6 @@ export const transforms = (
|
||||
|
||||
const plugins = new Array<Plugin>();
|
||||
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.targetHtml,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
plugins.push(new webpack.HotModuleReplacementPlugin());
|
||||
} else {
|
||||
|
||||
@@ -14,21 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AppConfig, Config } from '@backstage/config';
|
||||
import { BundlingPathsOptions } from './paths';
|
||||
import { AppConfig } from '../app-config';
|
||||
|
||||
export type BundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
checksEnabled: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
export type BuildOptions = BundlingPathsOptions & {
|
||||
statsJsonEnabled: boolean;
|
||||
appConfig: AppConfig[];
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
};
|
||||
|
||||
@@ -130,6 +130,10 @@ class PackageJsonHandler {
|
||||
|
||||
// Publish config can be removed the the target, skip in that case
|
||||
if (!targetPublishConf) {
|
||||
if (await this.prompt('Missing publishConfig, do you want to add it?')) {
|
||||
this.targetPkg.publishConfig = pkgPublishConf;
|
||||
await this.write();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,13 +53,17 @@ export const makeConfigs = async (
|
||||
|
||||
if (options.outputs.has(Output.cjs)) {
|
||||
output.push({
|
||||
file: 'dist/index.cjs.js',
|
||||
dir: 'dist',
|
||||
entryFileNames: 'index.cjs.js',
|
||||
chunkFileNames: 'cjs/[name]-[hash].js',
|
||||
format: 'commonjs',
|
||||
});
|
||||
}
|
||||
if (options.outputs.has(Output.esm)) {
|
||||
output.push({
|
||||
file: 'dist/index.esm.js',
|
||||
dir: 'dist',
|
||||
entryFileNames: 'index.esm.js',
|
||||
chunkFileNames: 'esm/[name]-[hash].js',
|
||||
format: 'module',
|
||||
});
|
||||
}
|
||||
@@ -67,6 +71,8 @@ export const makeConfigs = async (
|
||||
configs.push({
|
||||
input: 'src/index.ts',
|
||||
output,
|
||||
preserveEntrySignatures: 'strict',
|
||||
external: require('module').builtinModules,
|
||||
plugins: [
|
||||
peerDepsExternal({
|
||||
includeDependencies: true,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { rollup, RollupOptions } from 'rollup';
|
||||
import chalk from 'chalk';
|
||||
import { relative as relativePath } from 'path';
|
||||
@@ -83,5 +84,6 @@ async function build(config: RollupOptions) {
|
||||
|
||||
export const buildPackage = async (options: BuildOptions) => {
|
||||
const configs = await makeConfigs(options);
|
||||
await fs.remove(paths.resolveTarget('dist'));
|
||||
await Promise.all(configs.map(build));
|
||||
};
|
||||
|
||||
@@ -57,8 +57,7 @@ export function findRootPath(topPath: string): string {
|
||||
const exists = fs.pathExistsSync(packagePath);
|
||||
if (exists) {
|
||||
try {
|
||||
const contents = fs.readFileSync(packagePath, 'utf8');
|
||||
const data = JSON.parse(contents);
|
||||
const data = fs.readJsonSync(packagePath);
|
||||
if (data.name === 'root' || data.name.includes('backstage-e2e')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ export async function templatingTask(
|
||||
// List of local packages that we need to modify as a part of an E2E test
|
||||
const PATCH_PACKAGES = [
|
||||
'cli',
|
||||
'config',
|
||||
'config-loader',
|
||||
'core',
|
||||
'core-api',
|
||||
'dev-utils',
|
||||
@@ -143,6 +145,7 @@ export async function installWithLocalDeps(dir: string) {
|
||||
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
|
||||
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
delete pkgJson.devDependencies[`@backstage/${name}`];
|
||||
|
||||
await fs
|
||||
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
|
||||
@@ -173,7 +176,7 @@ export async function installWithLocalDeps(dir: string) {
|
||||
// types to dist/index.d.ts and the main:src field is removed.
|
||||
// Without this we get type checking errors in the e2e test
|
||||
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
|
||||
Task.section('Patchling local dependencies for e2e tests');
|
||||
Task.section('Patching local dependencies for e2e tests');
|
||||
|
||||
for (const name of PATCH_PACKAGES) {
|
||||
await Task.forItem(
|
||||
@@ -190,7 +193,11 @@ export async function installWithLocalDeps(dir: string) {
|
||||
|
||||
// We want dist to be used for e2e tests
|
||||
delete depJson['main:src'];
|
||||
depJson.types = 'dist/index.d.ts';
|
||||
for (const key of Object.keys(depJson.publishConfig)) {
|
||||
if (key !== 'access') {
|
||||
depJson[key] = depJson.publishConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
await fs
|
||||
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
app:
|
||||
title: Scaffolded Backstage App
|
||||
baseUrl: http://localhost:3000
|
||||
|
||||
organization:
|
||||
name: Acme Corporation
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { createApp } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import * as plugins from './plugins';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
@@ -33,9 +32,7 @@ const App: FC<{}> = () => {
|
||||
useStyles();
|
||||
return (
|
||||
<AppProvider>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
<AppComponent />
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"main:src": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "./config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"emitDeclarationOnly": false,
|
||||
"removeComments": true,
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# @backstage/config-loader
|
||||
|
||||
This package provides config loading functionality used by the backend, and CLI.
|
||||
|
||||
## Installation
|
||||
|
||||
Do not install this package directly, it is an internal package used by [@backstage/cli](https://www.npmjs.com/package/@backstage/cli), and [@backstage/backend-common](https://www.npmjs.com/package/@backstage/backend-common). Depend on either of those instead.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@backstage/config-loader",
|
||||
"description": "Config loading functionality used by Backstage backend, and CLI",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spotify/backstage",
|
||||
"directory": "packages/config-loader"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"fs-extra": "^9.0.0",
|
||||
"yaml": "^1.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -14,5 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DescriptorParsers } from './DescriptorParsers';
|
||||
export { YamlDescriptorParser } from './parsers/YamlDescriptorParser';
|
||||
export { loadConfig } from './loader';
|
||||
export type { LoadConfigOptions } from './types';
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 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 { readEnv } from './loader';
|
||||
|
||||
describe('readEnv', () => {
|
||||
it('should return empty config for empty env', () => {
|
||||
expect(readEnv({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty config for no matching keys', () => {
|
||||
expect(
|
||||
readEnv({
|
||||
NODE_ENV: 'production',
|
||||
NOPE_ENV: 'development',
|
||||
APP_CONFIG: 'foo',
|
||||
APP__CONFIG_derp: 'herp',
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create config from env', () => {
|
||||
expect(
|
||||
readEnv({
|
||||
NODE_ENV: 'production',
|
||||
APP_CONFIG_foo: '"bar"',
|
||||
APP_CONFIG_numbers_a: '1',
|
||||
APP_CONFIG_numbers_b: '2',
|
||||
APP_CONFIG_numbers_c: 'false',
|
||||
APP_CONFIG_numbers_d: undefined,
|
||||
APP_CONFIG_very_deep_nested_config_object: '{}',
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
foo: 'bar',
|
||||
numbers: { a: 1, b: 2, c: false },
|
||||
very: { deep: { nested: { config: { object: {} } } } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['APP_CONFIG__foo'],
|
||||
['APP_CONFIG_foo_'],
|
||||
['APP_CONFIG_fo_0'],
|
||||
['APP_CONFIG_fo/o'],
|
||||
['APP_CONFIG_fo o'],
|
||||
['APP_CONFIG_foo_(foo)_foo'],
|
||||
])('should reject invalid key %p', key => {
|
||||
expect(() => readEnv({ [key]: '0' })).toThrow(
|
||||
`Invalid env config key '${key.replace('APP_CONFIG_', '')}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([['hello'], ['"hello'], ['{'], ['}'], ['123abc']])(
|
||||
'should reject invalid value %p',
|
||||
value => {
|
||||
expect(() => readEnv({ APP_CONFIG_foo: value })).toThrow(
|
||||
/^Failed to parse JSON-serialized config value for key 'foo', SyntaxError: /,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should not allow null as a value', () => {
|
||||
expect(() =>
|
||||
readEnv({
|
||||
APP_CONFIG_foo: 'null',
|
||||
}),
|
||||
).toThrow(
|
||||
"Failed to parse JSON-serialized config value for key 'foo', Error: value may not be null",
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow duplicate values', () => {
|
||||
expect(() =>
|
||||
readEnv({
|
||||
APP_CONFIG_foo_bar: '1',
|
||||
APP_CONFIG_foo_bar_baz: '2',
|
||||
}),
|
||||
).toThrow(
|
||||
"Could not nest config for key 'foo_bar_baz' under existing value 'foo_bar'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow mixing of objects and other values', () => {
|
||||
expect(() =>
|
||||
readEnv({
|
||||
APP_CONFIG_nested_foo: '1',
|
||||
APP_CONFIG_nested: '2',
|
||||
}),
|
||||
).toThrow("Refusing to override existing config at key 'nested'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { findRootPath } from './paths';
|
||||
import { LoadConfigOptions } from './types';
|
||||
|
||||
const ENV_PREFIX = 'APP_CONFIG_';
|
||||
|
||||
// Update the same pattern in config package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
|
||||
export function readEnv(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
let config: JsonObject | undefined = undefined;
|
||||
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith(ENV_PREFIX)) {
|
||||
const key = name.replace(ENV_PREFIX, '');
|
||||
const keyParts = key.split('_');
|
||||
|
||||
let obj = (config = config ?? {});
|
||||
for (const [index, part] of keyParts.entries()) {
|
||||
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
|
||||
throw new TypeError(`Invalid env config key '${key}'`);
|
||||
}
|
||||
if (index < keyParts.length - 1) {
|
||||
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
const subKey = keyParts.slice(0, index + 1).join('_');
|
||||
throw new TypeError(
|
||||
`Could not nest config for key '${key}' under existing value '${subKey}'`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (part in obj) {
|
||||
throw new TypeError(
|
||||
`Refusing to override existing config at key '${key}'`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (parsedValue === null) {
|
||||
throw new Error('value may not be null');
|
||||
}
|
||||
obj[part] = parsedValue;
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config ? [config] : [];
|
||||
}
|
||||
|
||||
export async function readStaticConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<AppConfig[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
let { configPath } = options;
|
||||
if (!configPath) {
|
||||
configPath = resolvePath(
|
||||
findRootPath(fs.realpathSync(process.cwd())),
|
||||
'app-config.yaml',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const configYaml = await fs.readFile(configPath, 'utf8');
|
||||
const config = yaml.parse(configYaml);
|
||||
return [config];
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read static configuration file, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadConfig(
|
||||
options: LoadConfigOptions = {},
|
||||
): Promise<AppConfig[]> {
|
||||
const configs = [];
|
||||
|
||||
configs.push(...readEnv(process.env));
|
||||
configs.push(...(await readStaticConfig(options)));
|
||||
|
||||
return configs;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 { findRootPath } from './paths';
|
||||
|
||||
describe('findRootPath', () => {
|
||||
it('should find root path', () => {
|
||||
const rootPath = findRootPath(process.cwd());
|
||||
expect(typeof rootPath).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { dirname, resolve as resolvePath } from 'path';
|
||||
|
||||
/**
|
||||
* Looks for a package.json that has name: "root" to identify the root of the monorepo
|
||||
*
|
||||
* This is a copy of the same function in the CLI
|
||||
*/
|
||||
export function findRootPath(topPath: string): string {
|
||||
let path = topPath;
|
||||
|
||||
// Some sanity check to avoid infinite loop
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const packagePath = resolvePath(path, 'package.json');
|
||||
const exists = fs.pathExistsSync(packagePath);
|
||||
if (exists) {
|
||||
try {
|
||||
const data = fs.readJsonSync(packagePath);
|
||||
if (data.name === 'root' || data.name.includes('backstage-e2e')) {
|
||||
return path;
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse package.json file while searching for root, ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = dirname(path);
|
||||
if (newPath === path) {
|
||||
throw new Error(
|
||||
`No package.json with name "root" found as a parent of ${topPath}`,
|
||||
);
|
||||
}
|
||||
path = newPath;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Iteration limit reached when searching for root package.json at ${topPath}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type LoadConfigOptions = {
|
||||
// Config path, defaults to app-config.yaml in project root
|
||||
configPath?: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
rules: {
|
||||
'jest/expect-expect': 0,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# @backstage/config
|
||||
|
||||
This package provides a config API used by Backstage core, backend, and CLI.
|
||||
|
||||
## Installation
|
||||
|
||||
Do not install this package directly, it is an internal package used by [@backstage/core](https://www.npmjs.com/package/@backstage/core), [@backstage/cli](https://www.npmjs.com/package/@backstage/cli), and [@backstage/backend-common](https://www.npmjs.com/package/@backstage/backend-common). Depend on either of those instead.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@backstage/config",
|
||||
"description": "Config API used by Backstage core, backend, and CLI",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spotify/backstage",
|
||||
"directory": "packages/config"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
]
|
||||
}
|
||||
+8
-4
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LocationReaders } from './LocationReaders';
|
||||
export { FileLocationReader } from './readers/FileLocationReader';
|
||||
export { GitHubLocationReader } from './readers/GitHubLocationReader';
|
||||
export type { LocationReader } from './readers/types';
|
||||
export type {
|
||||
AppConfig,
|
||||
Config,
|
||||
JsonArray,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
export { ConfigReader } from './reader';
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from './ConfigReader';
|
||||
import { ConfigReader } from './reader';
|
||||
|
||||
const DATA = {
|
||||
zero: 0,
|
||||
+78
-82
@@ -14,15 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigApi, Config } from '../../definitions/ConfigApi';
|
||||
import { AppConfig } from '../../../app';
|
||||
import { AppConfig, Config, JsonValue, JsonObject } from './types';
|
||||
|
||||
// Update the same pattern in config-loader package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
|
||||
type JsonObject = { [key in string]: JsonValue };
|
||||
type JsonArray = JsonValue[];
|
||||
type JsonValue = JsonObject | JsonArray | number | string | boolean | null;
|
||||
|
||||
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -37,31 +33,14 @@ function typeOf(value: JsonValue | undefined): string {
|
||||
if (type === 'number' && isNaN(value as number)) {
|
||||
return 'nan';
|
||||
}
|
||||
if (type === 'string' && value === '') {
|
||||
return 'empty-string';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function typeErrorMessage(key: string, got: string, wanted: string) {
|
||||
return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`;
|
||||
}
|
||||
|
||||
function validateString(
|
||||
key: string,
|
||||
value: JsonValue | undefined,
|
||||
): value is string {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (value === '') {
|
||||
throw new TypeError(typeErrorMessage(key, 'empty-string', 'string'));
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'string'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class ConfigReader implements ConfigApi {
|
||||
static nullReader = new ConfigReader({});
|
||||
export class ConfigReader implements Config {
|
||||
private static readonly nullReader = new ConfigReader({});
|
||||
|
||||
static fromConfigs(configs: AppConfig[]): ConfigReader {
|
||||
if (configs.length === 0) {
|
||||
@@ -70,95 +49,112 @@ export class ConfigReader implements ConfigApi {
|
||||
|
||||
// Merge together all configs info a single config with recursive fallback
|
||||
// readers, giving the first config object in the array the highest priority.
|
||||
return configs.reduceRight((previousReader, nextConfig) => {
|
||||
return configs.reduceRight<ConfigReader>((previousReader, nextConfig) => {
|
||||
return new ConfigReader(nextConfig, previousReader);
|
||||
}, undefined);
|
||||
}, undefined!);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly data: JsonObject,
|
||||
private readonly fallback?: ConfigApi,
|
||||
private readonly fallback?: ConfigReader,
|
||||
) {}
|
||||
|
||||
getConfig(key: string): Config {
|
||||
getConfig(key: string): ConfigReader {
|
||||
const value = this.readValue(key);
|
||||
const fallbackConfig = this.fallback?.getConfig(key);
|
||||
if (isObject(value)) {
|
||||
return new ConfigReader(value, fallbackConfig);
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
|
||||
throw new TypeError(
|
||||
`Invalid type in config for key ${key}, got ${typeOf(
|
||||
value,
|
||||
)}, wanted object`,
|
||||
);
|
||||
}
|
||||
return fallbackConfig ?? ConfigReader.nullReader;
|
||||
}
|
||||
|
||||
getConfigArray(key: string): Config[] {
|
||||
const values = this.readValue(key);
|
||||
if (Array.isArray(values)) {
|
||||
return values.map((value, index) => {
|
||||
if (isObject(value)) {
|
||||
return new ConfigReader(value);
|
||||
getConfigArray(key: string): ConfigReader[] {
|
||||
const configs = this.readConfigValue<JsonObject[]>(key, values => {
|
||||
if (!Array.isArray(values)) {
|
||||
return { expected: 'object-array' };
|
||||
}
|
||||
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (!isObject(value)) {
|
||||
return { expected: 'object-array', value, key: `${key}[${index}]` };
|
||||
}
|
||||
throw new TypeError(
|
||||
typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (values !== undefined) {
|
||||
throw new TypeError(
|
||||
typeErrorMessage(key, typeOf(values), 'object-array'),
|
||||
);
|
||||
}
|
||||
return this.fallback?.getConfigArray(key) ?? [];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (configs ?? []).map(obj => new ConfigReader(obj));
|
||||
}
|
||||
|
||||
getNumber(key: string): number | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (typeof value === 'number' && !isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'number'));
|
||||
}
|
||||
return this.fallback?.getNumber(key);
|
||||
return this.readConfigValue(
|
||||
key,
|
||||
value => typeof value === 'number' || { expected: 'number' },
|
||||
);
|
||||
}
|
||||
|
||||
getBoolean(key: string): boolean | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean'));
|
||||
}
|
||||
return this.fallback?.getBoolean(key);
|
||||
return this.readConfigValue(
|
||||
key,
|
||||
value => typeof value === 'boolean' || { expected: 'boolean' },
|
||||
);
|
||||
}
|
||||
|
||||
getString(key: string): string | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (validateString(key, value)) {
|
||||
return value;
|
||||
}
|
||||
return this.fallback?.getString(key);
|
||||
return this.readConfigValue(
|
||||
key,
|
||||
value =>
|
||||
(typeof value === 'string' && value !== '') || { expected: 'string' },
|
||||
);
|
||||
}
|
||||
|
||||
getStringArray(key: string): string[] | undefined {
|
||||
const values = this.readValue(key);
|
||||
if (Array.isArray(values)) {
|
||||
return this.readConfigValue(key, values => {
|
||||
if (!Array.isArray(values)) {
|
||||
return { expected: 'string-array' };
|
||||
}
|
||||
for (const [index, value] of values.entries()) {
|
||||
const iKey = `${key}[${index}]`;
|
||||
if (!validateString(iKey, value)) {
|
||||
throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string'));
|
||||
if (typeof value !== 'string' || value === '') {
|
||||
return { expected: 'string-array', value, key: `${key}[${index}]` };
|
||||
}
|
||||
}
|
||||
return values as string[];
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private readConfigValue<T extends JsonValue>(
|
||||
key: string,
|
||||
validate: (
|
||||
value: JsonValue,
|
||||
) => { expected: string; value?: JsonValue; key?: string } | true,
|
||||
): T | undefined {
|
||||
const value = this.readValue(key);
|
||||
|
||||
if (value === undefined) {
|
||||
return this.fallback?.readConfigValue(key, validate);
|
||||
}
|
||||
if (values !== undefined) {
|
||||
throw new TypeError(
|
||||
typeErrorMessage(key, typeOf(values), 'string-array'),
|
||||
);
|
||||
if (value !== undefined) {
|
||||
const result = validate(value);
|
||||
if (result !== true) {
|
||||
const {
|
||||
key: keyName = key,
|
||||
value: theValue = value,
|
||||
expected,
|
||||
} = result;
|
||||
const typeName = typeOf(theValue);
|
||||
throw new TypeError(
|
||||
`Invalid type in config for key ${keyName}, got ${typeName}, wanted ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return this.fallback?.getStringArray(key);
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
private readValue(key: string): JsonValue | undefined {
|
||||
+24
-12
@@ -14,16 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type LocationReader = {
|
||||
/**
|
||||
* Reads the contents of a single location.
|
||||
*
|
||||
* @param type The type of location to read
|
||||
* @param target The location target (type-specific)
|
||||
* @returns The target contents, as a raw Buffer, or undefined if this type
|
||||
* was not meant to be consumed by this reader
|
||||
* @throws An error if the type was meant for this reader, but could not be
|
||||
* read
|
||||
*/
|
||||
tryRead(type: string, target: string): Promise<Buffer | undefined>;
|
||||
export type JsonObject = { [key in string]: JsonValue };
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonValue =
|
||||
| JsonObject
|
||||
| JsonArray
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| null;
|
||||
|
||||
export type AppConfig = JsonObject;
|
||||
|
||||
export type Config = {
|
||||
getConfig(key: string): Config;
|
||||
|
||||
getConfigArray(key: string): Config[];
|
||||
|
||||
getNumber(key: string): number | undefined;
|
||||
|
||||
getBoolean(key: string): boolean | undefined;
|
||||
|
||||
getString(key: string): string | undefined;
|
||||
|
||||
getStringArray(key: string): string[] | undefined;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@backstage/core-api",
|
||||
"description": "Internal Core API used by Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
@@ -28,7 +30,8 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@types/react": "^16.9",
|
||||
@@ -39,8 +42,8 @@
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.7",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
|
||||
@@ -51,6 +51,35 @@ describe('ApiProvider', () => {
|
||||
renderedHoc.getByText('hoc message: hello');
|
||||
});
|
||||
|
||||
it('should provide nested access to apis', () => {
|
||||
const aRef = createApiRef<string>({ id: 'a', description: '' });
|
||||
const bRef = createApiRef<string>({ id: 'b', description: '' });
|
||||
|
||||
const MyComponent = () => {
|
||||
const a = useApi(aRef);
|
||||
const b = useApi(bRef);
|
||||
return (
|
||||
<div>
|
||||
a={a} b={b}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderedHook = render(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[aRef, 'x'],
|
||||
[bRef, 'y'],
|
||||
])}
|
||||
>
|
||||
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
|
||||
<MyComponent />
|
||||
</ApiProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
renderedHook.getByText('a=z b=y');
|
||||
});
|
||||
|
||||
it('should ignore deps in prototype', () => {
|
||||
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
|
||||
const xRef = createApiRef<number>({ id: 'x', description: '' });
|
||||
|
||||
@@ -18,16 +18,20 @@ import React, { FC, createContext, useContext, ReactNode } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ApiRef } from './ApiRef';
|
||||
import { ApiHolder, TypesToApiRefs } from './types';
|
||||
import { ApiAggregator } from './ApiAggregator';
|
||||
|
||||
type Props = {
|
||||
type ApiProviderProps = {
|
||||
apis: ApiHolder;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Context = createContext<ApiHolder | undefined>(undefined);
|
||||
|
||||
export const ApiProvider: FC<Props> = ({ apis, children }) => {
|
||||
return <Context.Provider value={apis} children={children} />;
|
||||
export const ApiProvider: FC<ApiProviderProps> = ({ apis, children }) => {
|
||||
const parentHolder = useContext(Context);
|
||||
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
|
||||
|
||||
return <Context.Provider value={holder} children={children} />;
|
||||
};
|
||||
|
||||
ApiProvider.propTypes = {
|
||||
|
||||
@@ -49,4 +49,20 @@ describe('ApiRegistry', () => {
|
||||
expect(registry.get(x1Ref)).toBe(3);
|
||||
expect(registry.get(x2Ref)).toBe('y');
|
||||
});
|
||||
|
||||
it('should be created with API', () => {
|
||||
const reg1 = ApiRegistry.with(x1Ref, 3);
|
||||
const reg2 = reg1.with(x2Ref, 'y');
|
||||
const reg3 = reg2.with(x2Ref, 'z');
|
||||
const reg4 = reg3.with(x1Ref, 2);
|
||||
|
||||
expect(reg1.get(x1Ref)).toBe(3);
|
||||
expect(reg1.get(x2Ref)).toBe(undefined);
|
||||
expect(reg2.get(x1Ref)).toBe(3);
|
||||
expect(reg2.get(x2Ref)).toBe('y');
|
||||
expect(reg3.get(x1Ref)).toBe(3);
|
||||
expect(reg3.get(x2Ref)).toBe('z');
|
||||
expect(reg4.get(x1Ref)).toBe(2);
|
||||
expect(reg4.get(x2Ref)).toBe('z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,8 +42,28 @@ export class ApiRegistry implements ApiHolder {
|
||||
return new ApiRegistry(new Map(apis));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ApiRegistry with a single API implementation.
|
||||
*
|
||||
* @param api ApiRef for the API to add
|
||||
* @param impl Implementation of the API to add
|
||||
*/
|
||||
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
|
||||
return new ApiRegistry(new Map([[api, impl]]));
|
||||
}
|
||||
|
||||
constructor(private readonly apis: Map<ApiRef<unknown>, unknown>) {}
|
||||
|
||||
/**
|
||||
* Returns a new ApiRegistry with the provided API added to the existing ones.
|
||||
*
|
||||
* @param api ApiRef for the API to add
|
||||
* @param impl Implementation of the API to add
|
||||
*/
|
||||
with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
|
||||
return new ApiRegistry(new Map([...this.apis, [api, impl]]));
|
||||
}
|
||||
|
||||
get<T>(api: ApiRef<T>): T | undefined {
|
||||
return this.apis.get(api) as T | undefined;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,30 @@ export type OpenIdConnectApi = {
|
||||
logout(): Promise<void>;
|
||||
};
|
||||
|
||||
export type ProfileInfoOptions = {
|
||||
/**
|
||||
* If this is set to true, the user will not be prompted to log in,
|
||||
* and an empty profile will be returned if there is no existing session.
|
||||
*
|
||||
* This can be used to perform a check whether the user is logged in, or if you don't
|
||||
* want to force a user to be logged in, but provide functionality if they already are.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
optional?: boolean;
|
||||
};
|
||||
|
||||
export type ProfileInfoApi = {
|
||||
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
provider: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides authentication towards Google APIs and identities.
|
||||
*
|
||||
@@ -151,7 +175,9 @@ export type OpenIdConnectApi = {
|
||||
* Note that the ID token payload is only guaranteed to contain the user's numerical Google ID,
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*/
|
||||
export const googleAuthApiRef = createApiRef<OAuthApi & OpenIdConnectApi>({
|
||||
export const googleAuthApiRef = createApiRef<
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi
|
||||
>({
|
||||
id: 'core.auth.google',
|
||||
description: 'Provides authentication towards Google APIs and identities',
|
||||
});
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ConfigReader } from './ConfigReader';
|
||||
export { ConfigReader } from '@backstage/config';
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import { WebStorage } from './WebStorage';
|
||||
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
|
||||
|
||||
describe('WebStorage Storage API', () => {
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const createWebStorage = (
|
||||
@@ -161,4 +162,12 @@ describe('WebStorage Storage API', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a singleton for the same namespace and same bucket', async () => {
|
||||
const rootStorage = createWebStorage({
|
||||
namespace: '/Test/Mock/Thing/Thing ',
|
||||
});
|
||||
|
||||
expect(rootStorage.forBucket('test')).toBe(rootStorage.forBucket('test'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { Observable } from '../../../types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
const buckets = new Map<string, WebStorage>();
|
||||
|
||||
export class WebStorage implements StorageApi {
|
||||
constructor(
|
||||
private readonly namespace: string,
|
||||
@@ -46,7 +48,11 @@ export class WebStorage implements StorageApi {
|
||||
}
|
||||
|
||||
forBucket(name: string): WebStorage {
|
||||
return new WebStorage(`${this.namespace}/${name}`, this.errorApi);
|
||||
const bucketPath = `${this.namespace}/${name}`;
|
||||
if (!buckets.has(bucketPath)) {
|
||||
buckets.set(bucketPath, new WebStorage(bucketPath, this.errorApi));
|
||||
}
|
||||
return buckets.get(bucketPath)!;
|
||||
}
|
||||
|
||||
async set<T>(key: string, data: T): Promise<void> {
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
OpenIdConnectApi,
|
||||
IdTokenOptions,
|
||||
AccessTokenOptions,
|
||||
ProfileInfoApi,
|
||||
ProfileInfoOptions,
|
||||
ProfileInfo,
|
||||
} from '../../../definitions/auth';
|
||||
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
@@ -39,6 +42,7 @@ type CreateOptions = {
|
||||
};
|
||||
|
||||
export type GoogleAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
@@ -53,7 +57,7 @@ const DEFAULT_PROVIDER = {
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
@@ -69,6 +73,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: GoogleAuthResponse): GoogleSession {
|
||||
return {
|
||||
profile: res.profile,
|
||||
idToken: res.idToken,
|
||||
accessToken: res.accessToken,
|
||||
scopes: GoogleAuth.normalizeScopes(res.scope),
|
||||
@@ -123,6 +128,14 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
async getProfile(options: ProfileInfoOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
return session.profile;
|
||||
}
|
||||
|
||||
static normalizeScopes(scopes?: string | string[]): Set<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ProfileInfo } from '../../../definitions';
|
||||
|
||||
export type GoogleSession = {
|
||||
profile: ProfileInfo;
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, FC } from 'react';
|
||||
import React, { ComponentType, FC, useMemo } from 'react';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
|
||||
@@ -161,30 +161,58 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
|
||||
getProvider(): ComponentType<{}> {
|
||||
const Provider: FC<{}> = ({ children }) => {
|
||||
const appThemeApi = useMemo(
|
||||
() => AppThemeSelector.createWithStorage(this.themes),
|
||||
[],
|
||||
);
|
||||
|
||||
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
|
||||
const hasConfig = Boolean(this.configLoader);
|
||||
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
|
||||
|
||||
let childNode = children;
|
||||
let noConfigNode = undefined;
|
||||
|
||||
if (hasConfig && config.loading) {
|
||||
const { Progress } = this.components;
|
||||
childNode = <Progress />;
|
||||
noConfigNode = <Progress />;
|
||||
} else if (config.error) {
|
||||
const { BootErrorPage } = this.components;
|
||||
childNode = <BootErrorPage step="load-config" error={config.error} />;
|
||||
noConfigNode = (
|
||||
<BootErrorPage step="load-config" error={config.error} />
|
||||
);
|
||||
}
|
||||
|
||||
// Before the config is loaded we can't use a router, so exit early
|
||||
if (noConfigNode) {
|
||||
return (
|
||||
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
|
||||
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const configReader = ConfigReader.fromConfigs(config.value ?? []);
|
||||
const appApis = ApiRegistry.from([
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
|
||||
[configApiRef, configReader],
|
||||
]);
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
|
||||
const { Router } = this.components;
|
||||
let { pathname } = new URL(
|
||||
configReader.getString('app.baseUrl') ?? '/',
|
||||
'http://dummy.dev', // baseUrl can be specified as just a path
|
||||
);
|
||||
if (pathname.endsWith('/')) {
|
||||
pathname = pathname.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{childNode}</AppThemeProvider>
|
||||
<AppThemeProvider>
|
||||
<Router basename={pathname}>{children}</Router>
|
||||
</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { ApiHolder } from '../apis';
|
||||
import { AppTheme } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
@@ -29,13 +30,9 @@ export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{ basename?: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* TBD
|
||||
*/
|
||||
export type AppConfig = any;
|
||||
|
||||
/**
|
||||
* A function that loads in the App config that will be accessible via the ConfigApi.
|
||||
*
|
||||
|
||||
@@ -117,6 +117,10 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
window.location.reload(); // TODO(Rugvip): make this work without reload?
|
||||
}
|
||||
|
||||
async getCurrentSession() {
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
private async collapsedSessionRefresh(): Promise<T> {
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@backstage/core",
|
||||
"description": "Core API used by Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
@@ -28,8 +30,9 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-api": "0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"@backstage/core-api": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
@@ -52,8 +55,8 @@
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.7",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
|
||||
@@ -21,13 +21,13 @@ import privateExports, {
|
||||
defaultSystemIcons,
|
||||
BootErrorPageProps,
|
||||
AppConfigLoader,
|
||||
AppConfig,
|
||||
} from '@backstage/core-api';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import { ErrorPage } from '../layout/ErrorPage';
|
||||
import Progress from '../components/Progress';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
const { PrivateAppImpl } = privateExports;
|
||||
|
||||
@@ -87,9 +87,9 @@ export function createApp(options?: AppOptions) {
|
||||
}
|
||||
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
|
||||
return (
|
||||
<Router>
|
||||
<MemoryRouter>
|
||||
<ErrorPage status="501" statusMessage={message} />
|
||||
</Router>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createApp(options?: AppOptions) {
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
Router: BrowserRouter,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
|
||||
@@ -17,50 +17,86 @@
|
||||
import React from 'react';
|
||||
import DismissableBanner from './DismissableBanner';
|
||||
import { Link, Typography } from '@material-ui/core';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
CreateStorageApiOptions,
|
||||
ErrorApi,
|
||||
storageApiRef,
|
||||
StorageApi,
|
||||
WebStorage,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
export default {
|
||||
title: 'DismissableBanner',
|
||||
component: DismissableBanner,
|
||||
};
|
||||
|
||||
let errorApi: ErrorApi;
|
||||
const containerStyle = { width: '70%' };
|
||||
|
||||
const createWebStorage = (
|
||||
args?: Partial<CreateStorageApiOptions>,
|
||||
): StorageApi => {
|
||||
return WebStorage.create({
|
||||
errorApi: errorApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]);
|
||||
|
||||
export const Default = () => (
|
||||
<div style={containerStyle}>
|
||||
<DismissableBanner message="This is a dismissable banner" variant="info" />
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner"
|
||||
variant="info"
|
||||
id="default_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Error = () => (
|
||||
<div style={containerStyle}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with an error message"
|
||||
variant="error"
|
||||
/>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with an error message"
|
||||
variant="error"
|
||||
id="error_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const EmojisIncluded = () => (
|
||||
<div style={containerStyle}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with emojis: 🚀 💚 😆 "
|
||||
variant="info"
|
||||
/>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with emojis: 🚀 💚 😆 "
|
||||
variant="info"
|
||||
id="emojis_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithLink = () => (
|
||||
<div style={containerStyle}>
|
||||
<DismissableBanner
|
||||
message={
|
||||
<Typography>
|
||||
This is a dismissable banner with a link:{' '}
|
||||
<Link href="http://example.com" color="textSecondary">
|
||||
example.com
|
||||
</Link>
|
||||
</Typography>
|
||||
}
|
||||
variant="info"
|
||||
/>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message={
|
||||
<Typography>
|
||||
This is a dismissable banner with a link:{' '}
|
||||
<Link href="http://example.com" color="textSecondary">
|
||||
example.com
|
||||
</Link>
|
||||
</Typography>
|
||||
}
|
||||
variant="info"
|
||||
id="linked_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
// import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
|
||||
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
// import { createSetting } from 'shared/apis/settings';
|
||||
import DismissableBanner from './DismissableBanner';
|
||||
|
||||
describe('<DismissableBanner />', () => {
|
||||
it('renders the message and the popover', async () => {
|
||||
/*
|
||||
const mockSetting = createSetting({
|
||||
id: 'mockSetting',
|
||||
defaultValue: true,
|
||||
});
|
||||
*/
|
||||
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
// setting={mockSetting}
|
||||
message="test message"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
rendered.getByText('test message');
|
||||
|
||||
// fireEvent.click(rendered.getByTitle('Permanently dismiss this message'));
|
||||
// await waitForElementToBeRemoved(rendered.queryByText('test message'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
import DismissableBanner from './DismissableBanner';
|
||||
import {
|
||||
ApiRegistry,
|
||||
ApiProvider,
|
||||
storageApiRef,
|
||||
CreateStorageApiOptions,
|
||||
StorageApi,
|
||||
WebStorage,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
describe('<DismissableBanner />', () => {
|
||||
let apis: ApiRegistry;
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const createWebStorage = (
|
||||
args?: Partial<CreateStorageApiOptions>,
|
||||
): StorageApi => {
|
||||
return WebStorage.create({
|
||||
errorApi: mockErrorApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]);
|
||||
});
|
||||
|
||||
it('renders the message and the popover', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
// setting={mockSetting}
|
||||
message="test message"
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
const element = await rendered.findByText('test message');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gets placed in local storage on dismiss', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
// setting={mockSetting}
|
||||
message="test message"
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
const webstore = apis.get(storageApiRef);
|
||||
const notifications = webstore?.forBucket('notifications');
|
||||
const button = await rendered.findByTitle(
|
||||
'Permanently dismiss this message',
|
||||
);
|
||||
fireEvent.click(button);
|
||||
const dismissedBanners =
|
||||
notifications?.get<string[]>('dismissedBanners') ?? [];
|
||||
expect(
|
||||
dismissedBanners.includes('catalog_page_welcome_banner'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, ReactNode, useState } from 'react';
|
||||
import React, { FC, ReactNode, useState, useEffect } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core-api';
|
||||
import { useObservable } from 'react-use';
|
||||
import classNames from 'classnames';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import SnackbarContent from '@material-ui/core/SnackbarContent';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
// import { useSetting, Setting } from 'shared/apis/settings';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
@@ -54,23 +55,40 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
|
||||
type Props = {
|
||||
variant: 'info' | 'error';
|
||||
// setting: Setting<boolean>;
|
||||
message: ReactNode;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const DismissableBanner: FC<Props> = ({ variant, /* setting, */ message }) => {
|
||||
// const [show, setShown, loading] = useSetting(setting);
|
||||
const [show, setShown] = useState(true);
|
||||
const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
|
||||
const classes = useStyles();
|
||||
const storageApi = useApi(storageApiRef);
|
||||
const notificationsStore = storageApi.forBucket('notifications');
|
||||
const rawDismissedBanners =
|
||||
notificationsStore.get<string[]>('dismissedBanners') ?? [];
|
||||
|
||||
const [dismissedBanners, setDismissedBanners] = useState(
|
||||
new Set(rawDismissedBanners),
|
||||
);
|
||||
|
||||
const observedItems = useObservable(
|
||||
notificationsStore.observe$<string[]>('dismissedBanners'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (observedItems?.newValue) {
|
||||
const currentValue = observedItems?.newValue ?? [];
|
||||
setDismissedBanners(new Set(currentValue));
|
||||
}
|
||||
}, [observedItems?.newValue]);
|
||||
|
||||
const handleClick = () => {
|
||||
setShown(false);
|
||||
notificationsStore.set('dismissedBanners', [...dismissedBanners, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
open={show /* && !loading */}
|
||||
open={!dismissedBanners.has(id)}
|
||||
classes={{ root: classes.root }}
|
||||
>
|
||||
<SnackbarContent
|
||||
|
||||
@@ -113,6 +113,7 @@ const useHeaderStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
color: theme.palette.textSubtle,
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
position: 'static',
|
||||
wordBreak: 'normal',
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
type: {
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 9,
|
||||
fontSize: 11,
|
||||
opacity: 0.8,
|
||||
marginBottom: 10,
|
||||
marginBottom: theme.spacing(1),
|
||||
color: theme.palette.bursts.fontColor,
|
||||
},
|
||||
}));
|
||||
@@ -104,16 +104,11 @@ const TypeFragment: FC<TypeFragmentProps> = ({ type, typeLink, classes }) => {
|
||||
}
|
||||
|
||||
if (!typeLink) {
|
||||
return (
|
||||
// </Link>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
);
|
||||
// TODO: Add breadcrumbs.
|
||||
return <Typography className={classes.type}>{type}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
// <Link to={typeLink}>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
);
|
||||
return <Typography className={classes.type}>{type}</Typography>;
|
||||
};
|
||||
|
||||
const TitleFragment: FC<TitleFragmentProps> = ({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TODO(blam): Remove this implementation when the Tabs are ready
|
||||
// This is just a temporary solution to implementing tabs for now
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles, Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
tabsWrapper: {
|
||||
gridArea: 'pageSubheader',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
paddingLeft: theme.spacing(3),
|
||||
},
|
||||
defaultTab: {
|
||||
padding: theme.spacing(3, 3),
|
||||
...theme.typography.caption,
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 'bold',
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
selected: {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}));
|
||||
|
||||
export type Tab = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.tabsWrapper}>
|
||||
<Tabs
|
||||
indicatorColor="primary"
|
||||
textColor="inherit"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="scrollable auto tabs example"
|
||||
value={0}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<Tab
|
||||
label={tab.label}
|
||||
key={tab.id}
|
||||
value={index}
|
||||
className={styles.defaultTab}
|
||||
classes={{ selected: styles.selected }}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,48 +14,285 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core/styles';
|
||||
import { sidebarConfig } from './config';
|
||||
import { Avatar, Typography } from '@material-ui/core';
|
||||
import {
|
||||
Avatar,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Popover,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { blueGrey } from '@material-ui/core/colors';
|
||||
import { useSetState } from 'react-use';
|
||||
import { Skeleton } from '@material-ui/lab';
|
||||
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
|
||||
import LogoutIcon from '@material-ui/icons/PowerSettingsNew';
|
||||
import ControlPointIcon from '@material-ui/icons/ControlPoint';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => {
|
||||
const useStyles = makeStyles<Theme>(theme => {
|
||||
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
|
||||
return {
|
||||
root: {
|
||||
width: drawerWidthOpen,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#b5b5b5',
|
||||
paddingLeft: 18,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
color: '#b5b5b5',
|
||||
},
|
||||
avatar: {
|
||||
width: userBadgeDiameter,
|
||||
height: userBadgeDiameter,
|
||||
marginRight: 8,
|
||||
},
|
||||
purple: {
|
||||
color: theme.palette.getContrastText(blueGrey[500]),
|
||||
backgroundColor: blueGrey[500],
|
||||
},
|
||||
listItemText: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SessionListItem: FC<{
|
||||
classes: any;
|
||||
loading: boolean;
|
||||
title: string;
|
||||
icon: any;
|
||||
user: any;
|
||||
onSignIn: Function;
|
||||
onSignOut: Function;
|
||||
}> = ({
|
||||
classes,
|
||||
loading,
|
||||
title,
|
||||
icon,
|
||||
user,
|
||||
onSignIn,
|
||||
onSignOut,
|
||||
...props
|
||||
}) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>
|
||||
<Skeleton variant="circle" width={40} height={40} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Skeleton component="span" width={120} />}
|
||||
secondary={<Skeleton component="span" width={60} />}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton>
|
||||
<Skeleton variant="circle" width={24} height={24} />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Not functional yet to sign in from the sidebar
|
||||
if (!user) {
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
|
||||
<ListItemText primary="Sign In" secondary={title} />
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip
|
||||
title={`Sign in with ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignIn()}>
|
||||
<ControlPointIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
const { id, avatarUrl, avatarAlt } = user;
|
||||
|
||||
return (
|
||||
<ListItem {...props}>
|
||||
<ListItemAvatar>
|
||||
<Avatar src={avatarUrl} alt={avatarAlt}>
|
||||
{avatarAlt && avatarAlt[0].toUpperCase()}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
className={classes.listItemText}
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={title}
|
||||
/>
|
||||
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
|
||||
<Tooltip
|
||||
title={`Sign out from ${title}`}
|
||||
placement="bottom-end"
|
||||
PopperProps={{ style: { width: 120 } }}
|
||||
>
|
||||
<IconButton onClick={() => onSignOut()}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
const useGoogleLoginState = (open: boolean) => {
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
let didCancel = false;
|
||||
|
||||
if (open) {
|
||||
googleAuth.getProfile().then(_profile => {
|
||||
if (!didCancel) {
|
||||
setProfile(_profile);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [open, googleAuth]);
|
||||
|
||||
if (loading) {
|
||||
return { loading: true };
|
||||
}
|
||||
return { loading: false, isLoggedIn: !!profile, profile };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
hideName?: boolean;
|
||||
email: string;
|
||||
imageUrl?: string;
|
||||
name?: string;
|
||||
collapsedMode?: boolean;
|
||||
};
|
||||
|
||||
export const LoggedUserBadge: FC<Props> = ({
|
||||
imageUrl,
|
||||
name,
|
||||
hideName = false,
|
||||
email,
|
||||
collapsedMode = false,
|
||||
}) => {
|
||||
const [state, setState] = useSetState({
|
||||
open: false,
|
||||
anchorEl: null,
|
||||
});
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const googleLogin = useGoogleLoginState(state.open);
|
||||
|
||||
const handleOpen = (event: {
|
||||
preventDefault: () => void;
|
||||
currentTarget: any;
|
||||
}) => {
|
||||
// This prevents ghost click.
|
||||
event.preventDefault();
|
||||
setState({
|
||||
open: true,
|
||||
anchorEl: event.currentTarget,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState({
|
||||
open: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
googleAuth.getIdToken();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleGoogleSignOut = () => {
|
||||
googleAuth.logout();
|
||||
};
|
||||
|
||||
const classes = useStyles();
|
||||
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
const displayName = name ?? displayEmail;
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
|
||||
</div>
|
||||
<>
|
||||
<List dense>
|
||||
<ListItem className={classes.root} onClick={handleOpen}>
|
||||
<ListItemAvatar>
|
||||
{imageUrl ? (
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
) : (
|
||||
<Avatar
|
||||
alt={name}
|
||||
className={`${classes.avatar} ${classes.purple}`}
|
||||
>
|
||||
{avatarFallback[0]}
|
||||
</Avatar>
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
{!collapsedMode && (
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography className={classes.listItemText} variant="body2">
|
||||
{displayName}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
</List>
|
||||
<Popover
|
||||
transitionDuration={0}
|
||||
open={state.open}
|
||||
anchorEl={state.anchorEl}
|
||||
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
|
||||
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<List dense>
|
||||
<SessionListItem
|
||||
classes={classes}
|
||||
loading={googleLogin.loading}
|
||||
title="Google"
|
||||
icon={AccountCircleIcon}
|
||||
user={
|
||||
googleLogin.isLoggedIn && {
|
||||
id: googleLogin.profile?.email,
|
||||
avatarUrl: googleLogin.profile?.picture ?? '',
|
||||
avatarAlt:
|
||||
googleLogin.profile?.picture ?? googleLogin.profile?.email,
|
||||
}
|
||||
}
|
||||
onSignIn={handleGoogleSignIn}
|
||||
onSignOut={handleGoogleSignOut}
|
||||
/>
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useContext } from 'react';
|
||||
import React, { FC, useContext, useEffect, useState } from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import People from '@material-ui/icons/People';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
import { SidebarContext } from './config';
|
||||
import { SidebarItem } from './Items';
|
||||
import { LoggedUserBadge } from './LoggedUserBadge';
|
||||
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { SidebarPinStateContext } from './Page';
|
||||
import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api';
|
||||
|
||||
const ARROW_BUTTON_SIZE = 20;
|
||||
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
|
||||
@@ -58,18 +59,30 @@ export const SidebarUserBadge: FC<{}> = () => {
|
||||
SidebarPinStateContext,
|
||||
);
|
||||
const classes = useStyles({ isPinned });
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO(soapraj): How to observe if the user is logged in
|
||||
// TODO(soapraj): List all the providers supported by the app and let user log in from here
|
||||
googleAuth.getProfile({ optional: true }).then(googleProfile => {
|
||||
setProfile(googleProfile);
|
||||
});
|
||||
}, [googleAuth]);
|
||||
|
||||
const isUserLoggedIn = false;
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{isUserLoggedIn ? (
|
||||
<LoggedUserBadge
|
||||
imageUrl="https://via.placeholder.com/200/200"
|
||||
name="Victor Viale"
|
||||
hideName={!isOpen}
|
||||
/>
|
||||
{profile ? (
|
||||
<>
|
||||
<LoggedUserBadge
|
||||
email={profile.email}
|
||||
imageUrl={profile.picture}
|
||||
name={profile.name}
|
||||
collapsedMode={!isOpen}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
|
||||
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
|
||||
)}
|
||||
{isOpen && (
|
||||
<button
|
||||
|
||||
@@ -24,3 +24,4 @@ export * from './InfoCard';
|
||||
export * from './Page';
|
||||
export * from './Sidebar';
|
||||
export * from './TabbedCard';
|
||||
export * from './HeaderTabs';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@backstage/dev-utils",
|
||||
"description": "Utilities for developing Backstage plugins.",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
@@ -28,10 +30,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
import React, { FC, ComponentType, ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import BookmarkIcon from '@material-ui/icons/Bookmark';
|
||||
import {
|
||||
createApp,
|
||||
@@ -94,12 +93,10 @@ class DevAppBuilder {
|
||||
<AlertDisplay />
|
||||
<OAuthRequestDialog />
|
||||
{this.rootChildren}
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,13 @@ import {
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
errorApiRef,
|
||||
oauthRequestApiRef,
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
@@ -13,4 +17,18 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
|
||||
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
|
||||
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
export const apis = builder.build();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "storybook",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"description": "Storybook build for core package",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,7 +14,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/theme": "^0.1.1-alpha.6"
|
||||
"@backstage/theme": "^0.1.1-alpha.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^5.3.17",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@backstage/test-utils-core",
|
||||
"description": "Utilities to test Backstage core",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@backstage/test-utils",
|
||||
"description": "Utilities to test Backstage plugins and apps.",
|
||||
"version": "0.1.1-alpha.6",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
@@ -28,10 +30,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@backstage/core-api": "^0.1.1-alpha.6",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core-api": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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 { MockErrorApi } from './MockErrorApi';
|
||||
|
||||
async function ifResolved<T>(promise: Promise<T>): Promise<T | 'not-yet'> {
|
||||
return Promise.race([promise, Promise.resolve<'not-yet'>('not-yet')]);
|
||||
}
|
||||
|
||||
describe('MockErrorApi', () => {
|
||||
it('should throw errors by default', () => {
|
||||
const api = new MockErrorApi();
|
||||
expect(() => api.post(new Error('NOPE'))).toThrow(
|
||||
'MockErrorApi received unexpected error, Error: NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect errors', () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
api.post(new Error('e1'));
|
||||
api.post(new Error('e2'), { hidden: true });
|
||||
api.post(new Error('e3'));
|
||||
|
||||
expect(api.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error('e1'),
|
||||
},
|
||||
{
|
||||
error: new Error('e2'),
|
||||
context: { hidden: true },
|
||||
},
|
||||
{
|
||||
error: new Error('e3'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not emit values', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
api.error$().subscribe({
|
||||
next({ error }) {
|
||||
reject(error);
|
||||
},
|
||||
error(error) {
|
||||
reject(error);
|
||||
},
|
||||
complete() {
|
||||
reject(new Error('observable was completed'));
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => resolve('timed-out'), 100);
|
||||
});
|
||||
|
||||
await expect(promise).resolves.toBe('timed-out');
|
||||
});
|
||||
|
||||
it('should wait for errors', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
const wait1 = api.waitForError(/1/);
|
||||
const wait2 = api.waitForError(/2/);
|
||||
|
||||
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e0'));
|
||||
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e1'));
|
||||
await expect(ifResolved(wait1)).resolves.toEqual({
|
||||
error: new Error('e1'),
|
||||
});
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e2'), { hidden: true });
|
||||
await expect(ifResolved(wait2)).resolves.toEqual({
|
||||
error: new Error('e2'),
|
||||
context: { hidden: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should time out waiting for error', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
await expect(api.waitForError(/1/, 1)).rejects.toThrow(
|
||||
'Timed out waiting for error',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
ErrorApi,
|
||||
ErrorContext,
|
||||
errorApiRef,
|
||||
Observable,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
type Options = {
|
||||
collect?: boolean;
|
||||
};
|
||||
|
||||
type ErrorWithContext = {
|
||||
error: Error;
|
||||
context?: ErrorContext;
|
||||
};
|
||||
|
||||
type Waiter = {
|
||||
pattern: RegExp;
|
||||
resolve: (err: ErrorWithContext) => void;
|
||||
};
|
||||
|
||||
const nullObservable = {
|
||||
subscribe: () => ({ unsubscribe: () => {}, closed: true }),
|
||||
};
|
||||
|
||||
export class MockErrorApi implements ErrorApi {
|
||||
static factory = {
|
||||
implements: errorApiRef,
|
||||
deps: {},
|
||||
factory: () => new MockErrorApi(),
|
||||
};
|
||||
|
||||
private readonly errors = new Array<ErrorWithContext>();
|
||||
private readonly waiters = new Set<Waiter>();
|
||||
|
||||
constructor(private readonly options: Options = {}) {}
|
||||
|
||||
post(error: Error, context?: ErrorContext) {
|
||||
if (this.options.collect) {
|
||||
this.errors.push({ error, context });
|
||||
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.pattern.test(error.message)) {
|
||||
this.waiters.delete(waiter);
|
||||
waiter.resolve({ error, context });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`MockErrorApi received unexpected error, ${error}`);
|
||||
}
|
||||
|
||||
error$(): Observable<{ error: Error; context?: ErrorContext }> {
|
||||
return nullObservable;
|
||||
}
|
||||
|
||||
getErrors(): ErrorWithContext[] {
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
waitForError(
|
||||
pattern: RegExp,
|
||||
timeoutMs: number = 2000,
|
||||
): Promise<ErrorWithContext> {
|
||||
return new Promise<ErrorWithContext>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for error'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.add({ resolve, pattern });
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -14,5 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type { AppConfig } from './types';
|
||||
export { loadConfig } from './loaders';
|
||||
export { MockErrorApi } from './MockErrorApi';
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type AppConfig = any;
|
||||
export * from './ErrorApi';
|
||||
@@ -14,22 +14,106 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from './appWrappers';
|
||||
import { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
import { Route } from 'react-router';
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import {
|
||||
useApi,
|
||||
errorApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
} from '@backstage/core-api';
|
||||
import { MockErrorApi } from './apis';
|
||||
|
||||
describe('wrapInTestApp', () => {
|
||||
it('should provide routing', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<Route path="/route1">Route 1</Route>
|
||||
<Route path="/route2">Route 2</Route>
|
||||
</>,
|
||||
{ routeEntries: ['/route2'] },
|
||||
it('should provide routing and warn about missing act()', async () => {
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<Route path="/route1">Route 1</Route>
|
||||
<Route path="/route2">Route 2</Route>
|
||||
</>,
|
||||
{ routeEntries: ['/route2'] },
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
|
||||
// Wait for async actions to trigger the act() warnings that we assert below
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(error).toEqual([
|
||||
expect.stringMatching(
|
||||
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
|
||||
),
|
||||
expect.stringMatching(
|
||||
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a component in a test app without warning about missing act()', async () => {
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const Foo: FC<{}> = () => {
|
||||
return <p>foo</p>;
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(Foo);
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(error).toEqual([]);
|
||||
});
|
||||
|
||||
it('should render a node in a test app', async () => {
|
||||
const Foo: FC<{}> = () => {
|
||||
return <p>foo</p>;
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(<Foo />);
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide mock API implementations', async () => {
|
||||
const A: FC<{}> = () => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
errorApi.post(new Error('NOPE'));
|
||||
return null;
|
||||
};
|
||||
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
await expect(renderInTestApp(A)).rejects.toThrow('NOPE');
|
||||
});
|
||||
|
||||
expect(error).toEqual([
|
||||
expect.stringMatching(
|
||||
/^Error: Uncaught \[Error: MockErrorApi received unexpected error, Error: NOPE\]/,
|
||||
),
|
||||
expect.stringMatching(/^The above error occurred in the <A> component:/),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should allow custom API implementations', async () => {
|
||||
const mockErrorApi = new MockErrorApi({ collect: true });
|
||||
|
||||
const A: FC<{}> = () => {
|
||||
const errorApi = useApi(errorApiRef);
|
||||
useEffect(() => {
|
||||
errorApi.post(new Error('NOPE'));
|
||||
}, [errorApi]);
|
||||
return <p>foo</p>;
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={ApiRegistry.with(errorApiRef, mockErrorApi)}>
|
||||
<A />
|
||||
</ApiProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
expect(mockErrorApi.getErrors()).toEqual([{ error: new Error('NOPE') }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,15 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, ReactNode, FunctionComponent, FC } from 'react';
|
||||
import React, { ComponentType, ReactNode, FC, ReactElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import privateExports, {
|
||||
defaultSystemIcons,
|
||||
ApiTestRegistry,
|
||||
BootErrorPageProps,
|
||||
} from '@backstage/core-api';
|
||||
import { RenderResult } from '@testing-library/react';
|
||||
import { renderWithEffects } from '@backstage/test-utils-core';
|
||||
import { createMockApiRegistry } from './mockApiRegistry';
|
||||
const { PrivateAppImpl } = privateExports;
|
||||
|
||||
const NotFoundErrorPage = () => {
|
||||
@@ -43,18 +45,29 @@ type TestAppOptions = {
|
||||
routeEntries?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a component inside a Backstage test app, providing a mocked theme
|
||||
* and app context, along with mocked APIs.
|
||||
*
|
||||
* @param Component - A component or react node to render inside the test app.
|
||||
* @param options - Additional options for the rendering.
|
||||
*/
|
||||
export function wrapInTestApp(
|
||||
Component: ComponentType | ReactNode,
|
||||
options: TestAppOptions = {},
|
||||
) {
|
||||
): ReactElement {
|
||||
const { routeEntries = ['/'] } = options;
|
||||
const apis = createMockApiRegistry();
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
apis: new ApiTestRegistry(),
|
||||
apis,
|
||||
components: {
|
||||
NotFoundErrorPage,
|
||||
BootErrorPage,
|
||||
Progress,
|
||||
Router: ({ children }) => (
|
||||
<MemoryRouter initialEntries={routeEntries} children={children} />
|
||||
),
|
||||
},
|
||||
icons: defaultSystemIcons,
|
||||
plugins: [],
|
||||
@@ -72,16 +85,31 @@ export function wrapInTestApp(
|
||||
if (Component instanceof Function) {
|
||||
Wrapper = Component;
|
||||
} else {
|
||||
Wrapper = (() => Component) as FunctionComponent;
|
||||
Wrapper = (() => Component) as FC;
|
||||
}
|
||||
|
||||
const AppProvider = app.getProvider();
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={routeEntries}>
|
||||
<Route component={Wrapper} />
|
||||
</MemoryRouter>
|
||||
<Route component={Wrapper} />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component inside a Backstage test app, providing a mocked theme
|
||||
* and app context, along with mocked APIs.
|
||||
*
|
||||
* The render executes async effects similar to `renderWithEffects`. To avoid this
|
||||
* behavior, use a regular `render()` + `wrapInTestApp()` instead.
|
||||
*
|
||||
* @param Component - A component or react node to render inside the test app.
|
||||
* @param options - Additional options for the rendering.
|
||||
*/
|
||||
export async function renderInTestApp(
|
||||
Component: ComponentType | ReactNode,
|
||||
options: TestAppOptions = {},
|
||||
): Promise<RenderResult> {
|
||||
return renderWithEffects(wrapInTestApp(Component, options));
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './apis';
|
||||
export { default as mockBreakpoint } from './mockBreakpoint';
|
||||
export * from './appWrappers';
|
||||
export { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 { ApiTestRegistry } from '@backstage/core-api';
|
||||
import { MockErrorApi } from './apis';
|
||||
|
||||
export function createMockApiRegistry(): ApiTestRegistry {
|
||||
const registry = new ApiTestRegistry();
|
||||
|
||||
registry.register(MockErrorApi.factory);
|
||||
|
||||
return registry;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user