Merge branch 'master' of github.com:spotify/backstage into blam/react-router

* 'master' of github.com:spotify/backstage: (89 commits)
  Use URLSearchParams
  Use location?.type once more
  chore(catalog): clean up CatalogTable, use only Entity
  chore(catalog): clean up ComponentPage, use only Entity
  chore(catalog): tweak getEntityByName a bit, handle 404s properly
  chore(catalog/star): only set the cache if there are entries from the response
  chore(catalog/star): added a comment about why we are using a simple cache here
  chore(catalog/star): removing msw dependency, wrong branch
  chore(catalog): consistent use of named exports
  chore(msw): Added msw dependency
  chore(catalog/star): fixing issues with unmocked deps
  chore(catalog/star): adding a simple cache to stop flicker as a stopgap
  chore(catalog/star): reworking how the starring works, it now stores uri sort of references for entities
  fix(core): Tabs useEffect dependency list
  docs: format with prettier (#1218)
  Optional namespace and name as one part of URL
  docs/auth: added overview, oauth description and glossary
  docs: added plantuml generation script
  docs: added prettier config
  Remove deleted UserBadge component from Sidebar story
  ...
This commit is contained in:
blam
2020-06-11 17:36:19 +02:00
228 changed files with 6516 additions and 3129 deletions
+14 -14
View File
@@ -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",
+10 -19
View File
@@ -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>
<!--
+3 -6
View File
@@ -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>
);
+11 -3
View File
@@ -31,9 +31,11 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
SidebarUserSettings,
SidebarThemeToggle,
SidebarPinButton,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -56,7 +58,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>
@@ -84,7 +91,8 @@ const Root: FC<{}> = ({ children }) => (
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
<SidebarUserSettings />
<SidebarPinButton />
</Sidebar>
{children}
</SidebarPage>
+7 -2
View File
@@ -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,
@@ -27,12 +27,17 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
"helmet": "^3.22.0",
"morgan": "^1.10.0",
"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",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
+1
View File
@@ -17,3 +17,4 @@
export * from './errors';
export * from './logging';
export * from './middleware';
export * from './service';
@@ -0,0 +1,117 @@
/*
* 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 compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
import helmet from 'helmet';
import { Server } from 'http';
import { Logger } from 'winston';
import { getRootLogger } from '../logging';
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '../middleware';
import { ServiceBuilder } from './types';
const DEFAULT_PORT = 7000;
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
private logger: Logger | undefined;
private corsOptions: cors.CorsOptions | undefined;
private routers: [string, Router][];
constructor() {
this.routers = [];
}
setPort(port: number): ServiceBuilder {
this.port = port;
return this;
}
setLogger(logger: Logger): ServiceBuilder {
this.logger = logger;
return this;
}
enableCors(options: cors.CorsOptions): ServiceBuilder {
this.corsOptions = options;
return this;
}
addRouter(root: string, router: Router): ServiceBuilder {
this.routers.push([root, router]);
return this;
}
start(): Promise<Server> {
const app = express();
const { port, logger, corsOptions } = this.getOptions();
app.use(helmet());
if (corsOptions) {
app.use(cors(corsOptions));
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
for (const [root, route] of this.routers) {
app.use(root, route);
}
app.use(notFoundHandler());
app.use(errorHandler());
return new Promise((resolve, reject) => {
app.on('error', e => {
logger.error(`Failed to start up on port ${port}, ${e}`);
reject(e);
});
const server = app.listen(port, () => {
logger.info(`Listening on port ${port}`);
});
resolve(server);
});
}
private getOptions(): {
port: number;
logger: Logger;
corsOptions?: cors.CorsOptions;
} {
let port: number;
if (this.port !== undefined) {
port = this.port;
} else {
port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
}
let logger: Logger;
if (this.logger) {
logger = this.logger;
} else {
logger = getRootLogger();
}
return {
port,
logger,
corsOptions: this.corsOptions,
};
}
}
@@ -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 { ServiceBuilderImpl } from './ServiceBuilderImpl';
/**
* Creates a new service builder.
*/
export function createServiceBuilder() {
return new ServiceBuilderImpl();
}
@@ -0,0 +1,18 @@
/*
* 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 { createServiceBuilder } from './createServiceBuilder';
export type { ServiceBuilder } from './types';
@@ -0,0 +1,65 @@
/*
* 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 cors from 'cors';
import { Router } from 'express';
import { Server } from 'http';
import { Logger } from 'winston';
export type ServiceBuilder = {
/**
* Sets the port to listen on.
*
* If no port is specified, the service will first look for an environment
* variable named PORT and use that if present, otherwise it picks a default
* port (7000).
*
* @param port The port to listen on
*/
setPort(port: number): ServiceBuilder;
/**
* Sets the logger to use for service-specific logging.
*
* If no logger is given, the default root logger is used.
*
* @param logger A winston logger
*/
setLogger(logger: Logger): ServiceBuilder;
/**
* Enables CORS handling using the given settings.
*
* If this method is not called, the resulting service will not have any
* built in CORS handling.
*
* @param options Standard CORS options
*/
enableCors(options: cors.CorsOptions): ServiceBuilder;
/**
* Adds a router (similar to the express .use call) to the service.
*
* @param root The root URL to bind to (e.g. "/api/function1")
* @param router An express router
*/
addRouter(root: string, router: Router): ServiceBuilder;
/**
* Starts the server using the given settings.
*/
start(): Promise<Server>;
};
+8 -1
View File
@@ -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)
+9 -14
View File
@@ -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,26 +17,21 @@
"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",
"compression": "^1.7.4",
"cors": "^2.8.5",
"@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",
"esm": "^3.2.25",
"express": "^4.17.1",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.6",
"@types/compression": "^1.7.0",
"@types/cors": "^2.8.6",
"@backstage/cli": "^0.1.1-alpha.7",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47",
+19 -38
View File
@@ -22,27 +22,15 @@
* Happy hacking!
*/
import {
errorHandler,
getRootLogger,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { createServiceBuilder, getRootLogger } from '@backstage/backend-common';
import knex from 'knex';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
import identity from './plugins/identity';
import scaffolder from './plugins/scaffolder';
import sentry from './plugins/sentry';
import auth from './plugins/auth';
import identity from './plugins/identity';
import { PluginEnvironment } from './types';
const DEFAULT_PORT = 7000;
const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
function createEnv(plugin: string): PluginEnvironment {
const logger = getRootLogger().child({ type: 'plugin', plugin });
const database = knex({
@@ -57,30 +45,23 @@ function createEnv(plugin: string): PluginEnvironment {
}
async function main() {
const app = express();
const corsOptions: cors.CorsOptions = {
origin: 'http://localhost:3000',
credentials: true,
};
const service = createServiceBuilder()
.enableCors({
origin: 'http://localhost:3000',
credentials: true,
})
.addRouter('/catalog', await catalog(createEnv('catalog')))
.addRouter('/scaffolder', await scaffolder(createEnv('scaffolder')))
.addRouter(
'/sentry',
await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })),
)
.addRouter('/auth', await auth(createEnv('auth')))
.addRouter('/identity', await identity(createEnv('identity')));
app.use(helmet());
app.use(cors(corsOptions));
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/catalog', await catalog(createEnv('catalog')));
app.use('/scaffolder', await scaffolder(createEnv('scaffolder')));
app.use(
'/sentry',
await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })),
);
app.use('/auth', await auth(createEnv('auth')));
app.use('/identity', await identity(createEnv('identity')));
app.use(notFoundHandler());
app.use(errorHandler());
app.listen(PORT, () => {
getRootLogger().info(`Listening on port ${PORT}`);
await service.start().catch(err => {
console.log(err);
process.exit(1);
});
}
+4 -11
View File
@@ -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,
);
+1 -1
View File
@@ -9,7 +9,7 @@
"target": "es2019",
"module": "commonjs",
"esModuleInterop": true,
"lib": ["es2019"],
"lib": ["es2019", "dom"],
"types": ["node", "jest"]
}
}
+6 -3
View File
@@ -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",
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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');
+1
View File
@@ -67,6 +67,7 @@ module.exports = {
name: '@material-ui/icons',
message: "Please import '@material-ui/icons/<Icon>' instead.",
},
...require('module').builtinModules,
],
},
],
+6 -4
View File
@@ -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",
+6 -3
View File
@@ -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,
});
};
+5 -2
View File
@@ -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();
+9 -6
View File
@@ -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
};
+5 -2
View File
@@ -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
View File
@@ -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}`);
}
}
+7
View File
@@ -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}`);
+23 -3
View File
@@ -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 -5
View File
@@ -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'),
+16 -2
View File
@@ -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();
});
+1 -12
View File
@@ -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 {
+7 -4
View File
@@ -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[];
};
+4
View File
@@ -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;
}
+8 -2
View File
@@ -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));
};
+1 -2
View File
@@ -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;
}
+8 -2
View File
@@ -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',
@@ -174,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(
@@ -191,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",
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "./config/tsconfig.json",
"include": ["src"],
"exclude": ["**/*.test.*"],
"compilerOptions": {
"outDir": "dist",
"emitDeclarationOnly": false,
"removeComments": true,
"module": "CommonJS"
}
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+12
View File
@@ -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)
+44
View File
@@ -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}"
]
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 { loadConfig } from './loader';
export type { LoadConfigOptions } from './types';
+106
View File
@@ -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'");
});
});
+111
View File
@@ -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;
}
+24
View File
@@ -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');
});
});
+57
View File
@@ -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}`,
);
}
+20
View File
@@ -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;
};
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
'jest/expect-expect': 0,
},
};
+12
View File
@@ -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)
+39
View File
@@ -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}"
]
}
+24
View File
@@ -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.
*/
export type {
AppConfig,
Config,
JsonArray,
JsonObject,
JsonValue,
} from './types';
export { ConfigReader } from './reader';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConfigReader } from './ConfigReader';
import { ConfigReader } from './reader';
const DATA = {
zero: 0,
@@ -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 {
+41
View File
@@ -0,0 +1,41 @@
/*
* 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 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;
};
+8 -5
View File
@@ -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: '' });
+7 -3
View File
@@ -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');
});
});
+20
View File
@@ -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;
}
@@ -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> {
+34 -7
View File
@@ -13,8 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, FC } from 'react';
import React, { ComponentType, FC, useMemo } from 'react';
import { Route, Routes, Navigate } from 'react-router-dom';
import { AppContextProvider } from './AppContext';
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
@@ -142,30 +141,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>
);
+2 -5
View File
@@ -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.
*
+9 -6
View File
@@ -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",
@@ -51,8 +54,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",
+5 -4
View File
@@ -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 ?? [
@@ -22,14 +22,9 @@ type Props = ComponentProps<typeof MaterialButton> &
ComponentProps<typeof RouterLink>;
/**
* Thin wrapper on top of material-ui's Button component
* Makes the Button to utilise react-router
* Thin wrapper on top of material-ui's Link component
* Makes the Link to utilise react-router
*/
const LinkBehavior = React.forwardRef<any, Props>((props, ref) => (
<RouterLink ref={ref} {...props} />
));
export const Button = React.forwardRef<any, Props>((props, ref) => (
<MaterialButton ref={ref} component={LinkBehavior} {...props} />
<MaterialButton ref={ref} component={RouterLink} {...props} />
));
@@ -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',
},
}));
@@ -0,0 +1,27 @@
/*
* 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 { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { StyledTab } from './Tab';
describe('<Tab />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<StyledTab label="test" />));
expect(rendered.getByText('test')).toBeInTheDocument();
});
});
+62
View File
@@ -0,0 +1,62 @@
/*
* 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 { Tab, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledTabProps {
label?: string;
icon?: any; // TODO: define type for material-ui icons
isFirstNav?: boolean;
isFirstIndex?: boolean;
value?: any;
}
const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => {
if (isFirstIndex) {
if (isFirstNav) {
return '20px';
}
return '0';
}
return '40px';
};
const useStyles = makeStyles<BackstageTheme, StyledTabProps>(theme => ({
root: {
textTransform: 'none',
height: '64px',
fontWeight: theme.typography.fontWeightBold,
fontSize: theme.typography.pxToRem(13),
color: theme.palette.textSubtle,
marginLeft: props =>
tabMarginLeft(props.isFirstNav as boolean, props.isFirstIndex as boolean),
width: '130px',
minWidth: '130px',
'&:hover': {
outline: 'none',
backgroundColor: 'transparent',
color: theme.palette.textSubtle,
},
},
}));
export const StyledTab = (props: StyledTabProps) => {
const classes = useStyles(props);
const { isFirstNav, isFirstIndex, ...rest } = props;
return <Tab className={classes.root} disableRipple {...rest} />;
};
@@ -0,0 +1,52 @@
/*
* 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, { FC } from 'react';
import { Tabs, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledTabsProps {
value: number | boolean;
onChange: (event: React.ChangeEvent<{}>, newValue: number) => void;
}
const useStyles = makeStyles<BackstageTheme>(theme => ({
indicator: {
display: 'flex',
justifyContent: 'center',
backgroundColor: theme.palette.tabbar.indicator,
height: '4px',
},
flexContainer: {
alignItems: 'center',
},
root: {
'&:last-child': {
marginLeft: 'auto',
},
},
}));
export const StyledTabs: FC<StyledTabsProps> = props => {
const classes = useStyles(props);
return (
<Tabs
classes={classes}
{...props}
TabIndicatorProps={{ children: <span /> }}
/>
);
};
@@ -0,0 +1,60 @@
/*
* 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 { IconButton, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
interface StyledIconProps {
ariaLabel: string;
children: any;
isNext?: boolean;
onClick: any;
}
const useStyles = makeStyles<BackstageTheme, StyledIconProps>(() => ({
root: {
color: '#6E6E6E',
overflow: 'visible',
fontSize: '1.5rem',
textAlign: 'center',
borderRadius: '50%',
backgroundColor: '#E6E6E6',
marginLeft: props => (props.isNext ? 'auto' : '0'),
marginRight: props => (props.isNext ? '0' : '10px'),
'&:hover': {
backgroundColor: '#E6E6E6',
opacity: '1',
},
},
}));
export const StyledIcon = (props: StyledIconProps) => {
const classes = useStyles(props);
const { ariaLabel, onClick } = props;
return (
<IconButton
onClick={onClick}
className={classes.root}
size="small"
disableRipple
disableFocusRipple
aria-label={ariaLabel}
>
{props.children}
</IconButton>
);
};
@@ -0,0 +1,39 @@
/*
* 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, { FC } from 'react';
import Box from '@material-ui/core/Box';
export interface TabPanelProps {
children: any;
value?: any;
index?: number;
}
export const TabPanel: FC<TabPanelProps> = props => {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`scrollable-auto-tab-${index}`}
{...other}
>
{value === index && <Box p={3}>{children}</Box>}
</div>
);
};
@@ -0,0 +1,71 @@
/*
* 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 { Tabs } from './Tabs';
import AccessAlarmIcon from '@material-ui/icons/AccessAlarm';
export default {
title: 'Tabs',
component: Tabs,
};
const containerStyle = {};
export const Default = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Expandable = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(31)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Icons = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const IconsAndLabels = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
+165
View File
@@ -0,0 +1,165 @@
/*
* 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, {
FC,
useRef,
useEffect,
MutableRefObject,
useState,
} from 'react';
import { BackstageTheme } from '@backstage/theme';
import { AppBar } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import { chunkArray } from './utils';
import { useWindowSize } from 'react-use';
/* Import Components */
import { TabPanel } from './TabPanel';
import { StyledIcon } from './TabIcon';
import { StyledTab } from './Tab';
import { StyledTabs } from './TabBar';
/* Props Types */
export interface TabProps {
content: any;
label?: string;
icon?: any; // TODO: define type for material-ui icons
}
export interface TabsProps {
tabs: TabProps[];
}
const useStyles = makeStyles<BackstageTheme>((theme: BackstageTheme) => ({
root: {
flexGrow: 1,
width: '100%',
},
styledTabs: {
backgroundColor: theme.palette.background.paper,
},
appbar: {
boxShadow: 'none',
backgroundColor: theme.palette.background.paper,
paddingLeft: '10px',
paddingRight: '10px',
},
}));
export const Tabs: FC<TabsProps> = ({ tabs }) => {
const classes = useStyles();
const [value, setValue] = useState([0, 0]); // [selectedChunckedNavIndex, selectedIndex]
const [navIndex, setNavIndex] = useState(0);
const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0);
const [chunkedTabs, setChunkedTabs] = useState<TabProps[][]>([[]]);
const wrapper = useRef() as MutableRefObject<HTMLDivElement>;
const { width } = useWindowSize();
const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => {
setValue([navIndex, newValue]);
};
const navigateToPrevChunk = () => {
setNavIndex(navIndex - 1);
};
const navigateToNextChunk = () => {
setNavIndex(navIndex + 1);
};
const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length;
useEffect(() => {
// Each time the window is resized we calculate how many tabs wwe can render given the window width
const padding = 20; // The AppBar padding
const numberOfTabIcons = navIndex === 0 ? 1 : 2;
const wrapperWidth =
wrapper.current.offsetWidth - padding - numberOfTabIcons * 30;
const flattenIndex = value[0] * numberOfChunkedElement + value[1];
const newChunkedElementSize = Math.floor(wrapperWidth / 170);
setNumberOfChunkedElement(newChunkedElementSize);
setChunkedTabs(chunkArray([...tabs], newChunkedElementSize));
setValue([
Math.floor(flattenIndex / newChunkedElementSize),
flattenIndex % newChunkedElementSize,
]);
// eslint-disable-next-line
}, [width, tabs]);
const currentIndex = navIndex === value[0] ? value[1] : false;
return (
<div className={classes.root}>
<AppBar ref={wrapper} className={classes.appbar} position="static">
<div>
<StyledTabs value={currentIndex} onChange={handleChange}>
{navIndex !== 0 && (
<StyledIcon
onClick={navigateToPrevChunk}
ariaLabel="navigate-before"
>
<NavigateBeforeIcon />
</StyledIcon>
)}
{chunkedTabs[navIndex].map((tab, index) => (
<StyledTab
value={index}
isFirstIndex={index === 0}
isFirstNav={navIndex === 0}
key={index}
icon={tab.icon || undefined}
label={tab.label || undefined}
/>
))}
{hasNextNavIndex() && (
<StyledIcon
isNext
onClick={navigateToNextChunk}
ariaLabel="navigate-next"
>
<NavigateNextIcon />
</StyledIcon>
)}
</StyledTabs>
</div>
</AppBar>
{currentIndex !== false ? (
chunkedTabs[navIndex].map((tab, index) => (
<TabPanel key={index} value={index} index={currentIndex}>
{tab.content}
</TabPanel>
))
) : (
// Render if the selected tab index is outside the current rendered chunked array
<TabPanel
key="panel_outside_chunked_array"
value={value[1]}
index={value[1]}
>
{chunkedTabs[value[0]][value[1]].content}
</TabPanel>
)}
</div>
);
};
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export type { AppConfig } from './types';
export { loadConfig } from './loaders';
export { Tabs as default } from './Tabs';
@@ -0,0 +1,27 @@
/*
* 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 { TabProps } from './Tabs';
export const chunkArray = (
myArray: TabProps[],
chunkSize: number,
): TabProps[][] => {
const results = [];
while (myArray.length) {
results.push(myArray.splice(0, chunkSize));
}
return results;
};
+1
View File
@@ -41,3 +41,4 @@ export * from './components/Status';
export * from './components/Button';
export * from './components/Link';
export { default as WarningPanel } from './components/WarningPanel';
export { default as Tabs } from './components/Tabs';
+5 -10
View File
@@ -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> = ({
@@ -18,12 +18,13 @@
// 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),
@@ -48,7 +49,7 @@ export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
<div className={styles.tabsWrapper}>
<Tabs
indicatorColor="primary"
textColor="background"
textColor="inherit"
variant="scrollable"
scrollButtons="auto"
aria-label="scrollable auto tabs example"
+7 -2
View File
@@ -59,6 +59,7 @@ const useStyles = makeStyles<Theme>(theme => {
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
flex: '3 1 auto',
},
iconContainer: {
boxSizing: 'border-box',
@@ -84,6 +85,11 @@ const useStyles = makeStyles<Theme>(theme => {
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
secondaryAction: {
width: theme.spacing(6),
textAlign: 'center',
marginRight: theme.spacing(1),
},
selected: {
'&$root': {
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
@@ -148,7 +154,6 @@ export const SidebarItem: FC<SidebarItemProps> = ({
</NavLink>
);
}
return (
<NavLink
className={clsx(classes.root, classes.open)}
@@ -165,7 +170,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
{text}
</Typography>
)}
{children}
<div className={classes.secondaryAction}>{children}</div>
</NavLink>
);
};
@@ -1,298 +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, { FC, useState, useEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { sidebarConfig } from './config';
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>(theme => {
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
return {
root: {
width: drawerWidthOpen,
display: 'flex',
alignItems: 'center',
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 = {
email: string;
imageUrl?: string;
name?: string;
collapsedMode?: boolean;
};
export const LoggedUserBadge: FC<Props> = ({
imageUrl,
name,
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 (
<>
<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,35 +14,32 @@
* limitations under the License.
*/
import React, { FC, useContext, useEffect, useState } from 'react';
import React, { FC, useContext } from 'react';
import { makeStyles } from '@material-ui/core';
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 { SidebarContext } from './config';
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 => {
return {
root: {
position: 'relative',
alignSelf: 'stretch',
},
arrowButtonWrapper: {
position: 'absolute',
right: 0,
width: ARROW_BUTTON_SIZE,
height: ARROW_BUTTON_SIZE,
top: `calc(50% - ${ARROW_BUTTON_SIZE / 2}px)`,
top: -(theme.spacing(6) + ARROW_BUTTON_SIZE) / 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px 0px 0px 2px',
background: theme.palette.pinSidebarButton.icon,
color: theme.palette.pinSidebarButton.background,
background: theme.palette.pinSidebarButton.background,
color: theme.palette.pinSidebarButton.icon,
border: 'none',
outline: 'none',
cursor: 'pointer',
@@ -53,37 +50,15 @@ const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(theme => {
};
});
export const SidebarUserBadge: FC<{}> = () => {
export const SidebarPinButton: FC<{}> = () => {
const { isOpen } = useContext(SidebarContext);
const { isPinned, toggleSidebarPinState } = useContext(
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]);
return (
<div className={classes.root}>
{profile ? (
<>
<LoggedUserBadge
email={profile.email}
imageUrl={profile.picture}
name={profile.name}
collapsedMode={!isOpen}
/>
</>
) : (
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
)}
{isOpen && (
<button
className={classes.arrowButtonWrapper}
@@ -22,7 +22,6 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
@@ -55,6 +54,5 @@ export const SampleSidebar = () => (
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
</Sidebar>
);
@@ -0,0 +1,188 @@
/*
* 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, { useState, useContext, useEffect, useRef } from 'react';
import Collapse from '@material-ui/core/Collapse';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import Divider from '@material-ui/core/Divider';
import {
useApi,
googleAuthApiRef,
githubAuthApiRef,
ProfileInfo,
} from '@backstage/core-api';
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
type Provider = {
title: string;
api: any;
identity?: boolean;
isSignedIn: boolean;
icon: any;
};
const useProviders = () => {
const googleAuth = useApi(googleAuthApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [providers, setProviders] = useState<Provider[]>([
{
title: 'Google',
api: googleAuth,
identity: true,
isSignedIn: false,
icon: Star,
},
{
title: 'Github',
api: githubAuth,
isSignedIn: false,
icon: StarBorder,
},
]);
const setIsSignedIn = async () => {
const signInChecks = await Promise.all(
providers.map(provider =>
provider.identity
? provider.api.getIdToken({ optional: true })
: provider.api.getAccessToken('', { optional: true }),
),
);
signInChecks.map((result, i) => {
providers[i].isSignedIn = !!result;
});
setProviders(providers);
};
setIsSignedIn();
return providers;
};
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const providers = useProviders();
const [profile, setProfile] = useState<ProfileInfo>();
const classes = useStyles();
// TODO(soapraj): List all the providers supported by the app and let user log in from here
// TODO(soapraj): How to observe if the user is logged in
useEffect(() => {
const identityProvider = providers.find(
(provider: Provider) => provider.identity,
);
identityProvider?.api
.getProfile({ optional: true })
.then((userProfile: ProfileInfo) => {
setProfile(userProfile);
});
}, [providers, open]);
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
// Close the provider list when sidebar collapse
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName;
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
: () => (
<Avatar alt={name} className={classes.avatar}>
{avatarFallback[0]}
</Avatar>
);
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName || 'Guest'}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
<Collapse in={open} timeout="auto" unmountOnExit>
{providers.map((provider: Provider) => (
<SidebarItem
key={provider.title}
text={provider.title}
icon={provider.icon ?? StarBorder}
disableSelected
>
<IconButton
onClick={() =>
provider.isSignedIn
? provider.api.logout()
: provider.api.getAccessToken()
}
>
<Tooltip
placement="top"
arrow
title={
provider.isSignedIn
? `Logout from ${provider.title}`
: `Sign in to ${provider.title}`
}
>
<PowerButton
color={provider.isSignedIn ? 'secondary' : 'primary'}
/>
</Tooltip>
</IconButton>
</SidebarItem>
))}
</Collapse>
</>
);
}
+2 -1
View File
@@ -25,7 +25,7 @@ export {
SidebarSpacer,
} from './Items';
export { IntroCard, SidebarIntro } from './Intro';
export { SidebarUserBadge } from './UserBadge';
export { SidebarPinButton } from './PinButton';
export {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
@@ -33,3 +33,4 @@ export {
} from './config';
export type { SidebarContextType } from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
export { SidebarUserSettings } from './UserSettings';
+8 -6
View File
@@ -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",
+4 -7
View File
@@ -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 -2
View File
@@ -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",
+4 -2
View File
@@ -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": {
+8 -6
View File
@@ -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',
);
});
});

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