Merge remote-tracking branch 'origin/master' into ndudnik/filter-by-identity

This commit is contained in:
Nikita Nek Dudnik
2020-06-22 09:35:23 +02:00
145 changed files with 1799 additions and 795 deletions
+3
View File
@@ -116,3 +116,6 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary change files created by Vim
*.swp
+1 -1
View File
@@ -156,7 +156,7 @@ Integrators also configure closed source plugins locally from the monorepo.
We chose GitHub because it is the tool that we are most familiar with, so that
will naturally lead to integrations for GitHub being developed at an early
stage. Hosting this project on GitHub does not exclude integrations with
alternatives, such as GitLab or Bitbucket. We believe that in time there will be
alternatives, such as [GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab) or Bitbucket. We believe that in time there will be
plugins that will provide functionality for these tools as well. Hopefully,
contributed by the community! Also note, implementations of Backstage can be
hosted wherever you feel suits your needs best.
+2
View File
@@ -15,6 +15,7 @@
"@backstage/plugin-sentry": "^0.1.1-alpha.9",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.9",
"@backstage/plugin-welcome": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
@@ -22,6 +23,7 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
+14 -13
View File
@@ -15,23 +15,24 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
value: jest.fn(() => {
return {
matches: true,
addListener: jest.fn(),
removeListener: jest.fn(),
};
}),
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
});
it('should render', () => {
const rendered = render(<App />);
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
+43 -5
View File
@@ -14,10 +14,38 @@
* limitations under the License.
*/
// Find all active hot module APIs of all ancestors of a module, including the module itself
function findAllAncestors(_module: NodeModule): NodeModule[] {
const ancestors = new Array<NodeModule>();
const parentIds = new Set<string | number>();
function add(id: string | number, m: NodeModule) {
if (parentIds.has(id)) {
return;
}
parentIds.add(id);
ancestors.push(m);
for (const parentId of (m as any).parents) {
const parent = require.cache[parentId];
if (parent) {
add(parentId, parent);
}
}
}
add(_module.id, _module);
return ancestors;
}
/**
* This function allows devs to cleanup
* ongoing effects when module gets hot-reloaded
* useHotCleanup allows cleanup of ongoing effects when a module is
* hot-reloaded during development. The cleanup function will be called
* whenever the module itself or any of its parent modules is hot-reloaded.
*
* Useful for cleaning intervals, timers, requests etc
*
* @example
* ```ts
* const intervalId = setInterval(doStuff, 1000);
@@ -28,9 +56,19 @@
*/
export function useHotCleanup(_module: NodeModule, cancelEffect: () => void) {
if (_module.hot) {
_module.hot.addDisposeHandler(() => {
cancelEffect();
});
const ancestors = findAllAncestors(_module);
let cancelled = false;
const handler = () => {
if (!cancelled) {
cancelled = true;
cancelEffect();
}
};
for (const m of ancestors) {
m.hot?.addDisposeHandler(handler);
}
}
}
+2
View File
@@ -19,6 +19,8 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.9",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.9",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.9",
+19 -11
View File
@@ -27,6 +27,8 @@ import {
getRootLogger,
useHotMemoize,
} from '@backstage/backend-common';
import { ConfigReader, AppConfig } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
import knex from 'knex';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
@@ -35,20 +37,26 @@ import scaffolder from './plugins/scaffolder';
import sentry from './plugins/sentry';
import { PluginEnvironment } from './types';
function createEnv(plugin: string): PluginEnvironment {
const logger = getRootLogger().child({ type: 'plugin', plugin });
const database = knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return { logger, database };
function makeCreateEnv(loadedConfigs: AppConfig[]) {
const config = ConfigReader.fromConfigs(loadedConfigs);
return (plugin: string): PluginEnvironment => {
const logger = getRootLogger().child({ type: 'plugin', plugin });
const database = knex({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return { logger, database, config };
};
}
async function main() {
const createEnv = makeCreateEnv(await loadConfig());
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
+5 -2
View File
@@ -17,6 +17,9 @@
import { createRouter } from '@backstage/plugin-auth-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
return await createRouter({ logger });
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
+2
View File
@@ -16,8 +16,10 @@
import Knex from 'knex';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
export type PluginEnvironment = {
logger: Logger;
database: Knex;
config: Config;
};
+3 -3
View File
@@ -1,9 +1,7 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.9",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
@@ -22,8 +20,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.9",
"@types/yup": "^0.28.2",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
"yup": "^0.28.5"
},
"devDependencies": {
+9 -2
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { JsonObject } from '@backstage/config';
/**
* The format envelope that's common to all versions/kinds of entity.
*
@@ -39,7 +41,7 @@ export type Entity = {
/**
* The specification data describing the entity itself.
*/
spec?: object;
spec?: JsonObject;
};
/**
@@ -48,7 +50,7 @@ export type Entity = {
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type EntityMeta = {
export type EntityMeta = JsonObject & {
/**
* A globally unique ID for the entity.
*
@@ -112,3 +114,8 @@ export type EntityMeta = {
*/
annotations?: Record<string, string>;
};
/**
* The keys of EntityMeta that are auto-generated.
*/
export const entityMetaGeneratedFields = ['uid', 'etag', 'generation'] as const;
@@ -14,5 +14,12 @@
* limitations under the License.
*/
export { entityMetaGeneratedFields } from './Entity';
export type { Entity, EntityMeta } from './Entity';
export * from './policies';
export {
entityHasChanges,
generateEntityEtag,
generateEntityUid,
generateUpdatedEntity,
} from './util';
@@ -0,0 +1,204 @@
/*
* 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 lodash from 'lodash';
import {
generateEntityEtag,
generateEntityUid,
entityHasChanges,
generateUpdatedEntity,
} from './util';
import { Entity } from './Entity';
describe('util', () => {
describe('generateEntityUid', () => {
it('generates randomness', () => {
expect(generateEntityUid()).not.toEqual('');
expect(generateEntityUid()).not.toEqual(generateEntityUid());
});
});
describe('generateEntityEtag', () => {
it('generates randomness', () => {
expect(generateEntityEtag()).not.toEqual('');
expect(generateEntityEtag()).not.toEqual(generateEntityEtag());
});
});
describe('entityHasChanges', () => {
let a: Entity;
beforeEach(() => {
a = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'name',
custom: 'custom',
labels: {
labelKey: 'labelValue',
},
annotations: {
annotationKey: 'annotationValue',
},
},
spec: {
a: 'a',
},
};
});
it('happy path: clone has no changes', () => {
const b = lodash.cloneDeep(a);
expect(entityHasChanges(a, b)).toBe(false);
});
it('detects root field changes', () => {
let b: any = lodash.cloneDeep(a);
b.apiVersion += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.apiVersion;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.kind += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.kind;
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects metadata changes', () => {
let b: any = lodash.cloneDeep(a);
b.metadata.name += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.custom;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.custom;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.labels.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.labels.labelKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
});
it('detects annotation changes, but not removals', () => {
let b: any = lodash.cloneDeep(a);
b.metadata.annotations.annotationKey += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.metadata.annotations.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.metadata.annotations.annotationKey;
expect(entityHasChanges(a, b)).toBe(false);
});
it('detects spec changes', () => {
let b: any = lodash.cloneDeep(a);
b.spec.a += 'a';
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
delete b.spec.a;
expect(entityHasChanges(a, b)).toBe(true);
b = lodash.cloneDeep(a);
b.spec.n = 'n';
expect(entityHasChanges(a, b)).toBe(true);
});
});
describe('generateUpdatedEntity', () => {
let a: Entity;
let b: any;
beforeEach(() => {
a = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
uid: 'da921f56-f655-4e6e-9b8b-bb19a57818d8',
etag: 'NzY5NDA5NzQtYmEwNC00MDY0LWFiYmItNTYxYzQxM2JhZDcx',
generation: 2,
name: 'name',
custom: 'custom',
labels: {
labelKey: 'labelValue',
},
annotations: {
annotationKey: 'annotationValue',
},
},
spec: {
a: 'a',
},
};
b = lodash.cloneDeep(a);
delete b.metadata.uid;
delete b.metadata.etag;
delete b.metadata.generation;
});
it('happy path: running on itself leaves it unchanged', () => {
const result = generateUpdatedEntity(a, b);
expect(result).toEqual(a);
});
it('bumps etag and generation when spec is changed', () => {
b.spec.a += 'a';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation! + 1);
expect(result.spec).toEqual({ a: 'aa' });
});
it('bumps only etag when other things than spec are changed', () => {
b.metadata.n = 'n';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.n).toEqual('n');
});
it('retains new annotations', () => {
b.metadata.annotations.annotationKey = 'changedValue';
b.metadata.annotations.newKey = 'newValue';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.annotations).toEqual({
annotationKey: 'changedValue',
newKey: 'newValue',
});
});
it('retains old annotations', () => {
b.metadata.annotations.newKey = 'newValue';
const result = generateUpdatedEntity(a, b);
expect(result.metadata.uid).toEqual(a.metadata.uid);
expect(result.metadata.etag).not.toEqual(a.metadata.etag);
expect(result.metadata.generation).toEqual(a.metadata.generation);
expect(result.metadata.annotations).toEqual({
annotationKey: 'annotationValue',
newKey: 'newValue',
});
});
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* 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 lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Entity } from './Entity';
/**
* Generates a new random UID for an entity.
*
* @returns A string with enough randomness to uniquely identify an entity
*/
export function generateEntityUid(): string {
return uuidv4();
}
/**
* Generates a new random Etag for an entity.
*
* @returns A string with enough randomness to uniquely identify an entity
* revision
*/
export function generateEntityEtag(): string {
return Buffer.from(uuidv4(), 'utf8').toString('base64').replace(/[^\w]/g, '');
}
/**
* Checks whether there are any significant changes going from the previous to
* the next version of this entity.
*
* Significance, in this case, means that we do not compare generated fields
* such as uid, etag and generation, and we only check that no new annotations
* are added or existing annotations were changed (since they are effectively
* merged when doing updates).
*
* @param previous The old state of the entity
* @param next The new state of the entity
*/
export function entityHasChanges(previous: Entity, next: Entity): boolean {
if (entityHasAnnotationChanges(previous, next)) {
return true;
}
const e1 = lodash.cloneDeep(previous);
const e2 = lodash.cloneDeep(next);
if (!e1.metadata.labels) {
e1.metadata.labels = {};
}
if (!e2.metadata.labels) {
e2.metadata.labels = {};
}
// Remove generated fields
delete e1.metadata.uid;
delete e1.metadata.etag;
delete e1.metadata.generation;
delete e2.metadata.uid;
delete e2.metadata.etag;
delete e2.metadata.generation;
// Remove already compared things
delete e1.metadata.annotations;
delete e2.metadata.annotations;
return !lodash.isEqual(e1, e2);
}
/**
* Takes an old revision of an entity and a new desired state, and merges
* them into a complete new state.
*
* The previous revision is expected to be a complete model loaded from the
* catalog, including the uid, etag and generation fields.
*
* @param previous The old state of the entity
* @param next The new state of the entity
* @returns An entity with the merged state of both
*/
export function generateUpdatedEntity(previous: Entity, next: Entity): Entity {
const { uid, etag, generation } = previous.metadata;
if (!uid || !etag || !generation) {
throw new Error('Previous entity must have uid, etag and generation');
}
const result = lodash.cloneDeep(next);
// Annotations are merged, with the new ones taking precedence
if (previous.metadata.annotations) {
next.metadata.annotations = {
...previous.metadata.annotations,
...next.metadata.annotations,
};
}
// Generated fields are copied and updated
const bumpEtag = entityHasChanges(previous, result);
const bumpGeneration = !lodash.isEqual(previous.spec, result.spec);
result.metadata.uid = uid;
result.metadata.etag = bumpEtag ? generateEntityEtag() : etag;
result.metadata.generation = bumpGeneration ? generation + 1 : generation;
return result;
}
function entityHasAnnotationChanges(previous: Entity, next: Entity): boolean {
// Since the next annotations get merged into the previous, extract only
// the overlapping keys and check if their values match.
if (next.metadata.annotations) {
if (!previous.metadata.annotations) {
return true;
}
if (
!lodash.isEqual(
next.metadata.annotations,
lodash.pick(
previous.metadata.annotations,
Object.keys(next.metadata.annotations),
),
)
) {
return true;
}
}
return false;
}
+3 -22
View File
@@ -25,32 +25,13 @@ async function getConfig() {
return require(path.resolve('jest.config.ts'));
}
const moduleNameMapper = {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
};
// Only point to src/ if we're not in CI, there we just build packages first anyway
if (!process.env.CI) {
const LernaProject = require('@lerna/project');
const project = new LernaProject(path.resolve('.'));
const packages = await project.getPackages();
// To avoid having to build all deps inside the monorepo before running tests,
// we point directory to src/ where applicable.
// For example, @backstage/core = <repo-root>/packages/core/src/index.ts is added to moduleNameMapper
for (const pkg of packages) {
const mainSrc = pkg.get('main:src');
if (mainSrc) {
moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
}
}
}
const options = {
rootDir: path.resolve('src'),
coverageDirectory: path.resolve('coverage'),
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
moduleNameMapper,
moduleNameMapper: {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
},
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
+3 -10
View File
@@ -33,9 +33,6 @@ import { BundlingOptions, BackendBundlingOptions } from './types';
export function resolveBaseUrl(config: Config): URL {
const baseUrl = config.getString('app.baseUrl');
if (!baseUrl) {
throw new Error('app.baseUrl must be set in config');
}
try {
return new URL(baseUrl, 'http://localhost:3000');
} catch (error) {
@@ -52,9 +49,6 @@ export function createConfig(
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) {
@@ -115,7 +109,7 @@ export function createConfig(
entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
mainFields: ['main:src', 'browser', 'module', 'main'],
mainFields: ['browser', 'module', 'main'],
plugins: [
new ModuleScopePlugin(
[paths.targetSrc, paths.targetDev],
@@ -184,12 +178,11 @@ export function createBackendConfig(
context: paths.targetPath,
entry: [
'webpack/hot/poll?100',
paths.targetEntry,
...(paths.targetRunFile ? [paths.targetRunFile] : []),
paths.targetRunFile ? paths.targetRunFile : paths.targetEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
mainFields: ['main:src', 'browser', 'module', 'main'],
mainFields: ['browser', 'module', 'main'],
modules: [paths.targetNodeModules, paths.rootNodeModules],
plugins: [
new ModuleScopePlugin(
+1 -4
View File
@@ -171,9 +171,7 @@ export async function installWithLocalDeps(dir: string) {
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src.
// For example node_modules/@backstage/core/packages.json is rewritten to point
// types to dist/index.d.ts and the main:src field is removed.
// dist instead of the local src, using the field overrides in publishConfig.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patching local dependencies for e2e tests');
@@ -192,7 +190,6 @@ export async function installWithLocalDeps(dir: string) {
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
delete depJson['main:src'];
for (const key of Object.keys(depJson.publishConfig)) {
if (key !== 'access') {
depJson[key] = depJson.publishConfig[key];
@@ -31,6 +31,9 @@
"lerna": "^3.20.2",
"prettier": "^1.19.1"
},
"resolutions": {
"**/esbuild": "0.5.3"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -8,10 +8,12 @@
"@material-ui/lab": "4.0.0-alpha.45",
"@backstage/cli": "^{{version}}",
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
@@ -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>
<!--
@@ -1,10 +1,22 @@
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
it('should render', () => {
const rendered = render(<App />);
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -1,26 +1,7 @@
import { makeStyles } from '@material-ui/core';
import { createApp } from '@backstage/core';
import React, { FC } from 'react';
import * as plugins from './plugins';
const useStyles = makeStyles(theme => ({
'@global': {
html: {
height: '100%',
fontFamily: theme.typography.fontFamily,
},
body: {
height: '100%',
fontFamily: theme.typography.fontFamily,
'overscroll-behavior-y': 'none',
},
a: {
color: 'inherit',
textDecoration: 'none',
},
},
}));
const app = createApp({
plugins: Object.values(plugins),
});
@@ -29,15 +10,12 @@ const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => {
useStyles();
return (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
};
const App: FC<{}> = () => (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
export default App;
@@ -1,8 +1,7 @@
{
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true,
"publishConfig": {
@@ -1,8 +1,7 @@
{
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
+6 -3
View File
@@ -45,9 +45,12 @@ describe('readEnv', () => {
}),
).toEqual([
{
foo: 'bar',
numbers: { a: 1, b: 2, c: false },
very: { deep: { nested: { config: { object: {} } } } },
data: {
foo: 'bar',
numbers: { a: 1, b: 2, c: false },
very: { deep: { nested: { config: { object: {} } } } },
},
context: 'env',
},
]);
});
+3 -3
View File
@@ -42,7 +42,7 @@ 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;
let data: JsonObject | undefined = undefined;
for (const [name, value] of Object.entries(env)) {
if (!value) {
@@ -52,7 +52,7 @@ export function readEnv(env: {
const key = name.replace(ENV_PREFIX, '');
const keyParts = key.split('_');
let obj = (config = config ?? {});
let obj = (data = data ?? {});
for (const [index, part] of keyParts.entries()) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid env config key '${key}'`);
@@ -87,5 +87,5 @@ export function readEnv(env: {
}
}
return config ? [config] : [];
return data ? [{ data, context: 'env' }] : [];
}
+11 -5
View File
@@ -38,11 +38,14 @@ describe('readConfigFile', () => {
} as ReaderContext);
await expect(config).resolves.toEqual({
app: {
title: 'Test',
x: 1,
y: [true],
data: {
app: {
title: 'Test',
x: 1,
y: [true],
},
},
context: 'app-config.yaml',
});
});
@@ -83,7 +86,10 @@ describe('readConfigFile', () => {
});
await expect(config).resolves.toEqual({
app: 'secret',
data: {
app: 'secret',
},
context: 'app-config.yaml',
});
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
});
+14 -7
View File
@@ -14,16 +14,20 @@
* limitations under the License.
*/
import { AppConfig, JsonObject, JsonValue } from '@backstage/config';
import { basename } from 'path';
import yaml from 'yaml';
import { isObject } from './utils';
import { JsonValue, JsonObject } from '@backstage/config';
import { ReaderContext } from './types';
import { isObject } from './utils';
/**
* Reads and parses, and validates, and transforms a single config file.
* The transformation rewrites any special values, like the $secret key.
*/
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
export async function readConfigFile(
filePath: string,
ctx: ReaderContext,
): Promise<AppConfig> {
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
@@ -63,9 +67,12 @@ export async function readConfigFile(filePath: string, ctx: ReaderContext) {
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
// undefined covers optional fields
if (value !== undefined) {
const result = await transform(value, `${path}.${key}`);
if (result !== undefined) {
out[key] = result;
}
}
}
@@ -76,5 +83,5 @@ export async function readConfigFile(filePath: string, ctx: ReaderContext) {
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return finalConfig;
return { data: finalConfig, context: basename(filePath) };
}
+2 -1
View File
@@ -122,7 +122,7 @@ export async function readSecret(
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
let value: JsonValue = await parser(content);
let value: JsonValue | undefined = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
@@ -132,6 +132,7 @@ export async function readSecret(
}
value = value[part];
}
return String(value);
}
+3
View File
@@ -29,6 +29,9 @@
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"lodash": "^4.17.15"
},
"devDependencies": {
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0"
+341 -33
View File
@@ -37,72 +37,112 @@ const DATA = {
};
function expectValidValues(config: ConfigReader) {
expect(config.keys()).toEqual(Object.keys(DATA));
expect(config.get('zero')).toBe(0);
expect(config.getNumber('zero')).toBe(0);
expect(config.getNumber('one')).toBe(1);
expect(config.getOptional('true')).toBe(true);
expect(config.getBoolean('true')).toBe(true);
expect(config.getBoolean('false')).toBe(false);
expect(config.getString('string')).toBe('string');
expect(config.get('strings')).toEqual(['string1', 'string2']);
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
expect(config.getConfig('nested').getNumber('one')).toBe(1);
expect(config.get('nested')).toEqual({
one: 1,
string: 'string',
strings: ['string1', 'string2'],
});
expect(config.getConfig('nested').getString('string')).toBe('string');
expect(config.getConfig('nested').getStringArray('strings')).toEqual([
'string1',
'string2',
]);
expect(
config.getOptionalConfig('nested')!.getStringArray('strings'),
).toEqual(['string1', 'string2']);
expect(config.getOptional('missing')).toBe(undefined);
expect(config.getOptionalConfig('missing')).toBe(undefined);
expect(config.getOptionalConfigArray('missing')).toBe(undefined);
expect(config.getNumber('zero')).toBe(0);
expect(config.getBoolean('true')).toBe(true);
expect(config.getString('string')).toBe('string');
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
const [config1, config2, config3] = config.getConfigArray('nestlings');
expect(config1.getBoolean('boolean')).toBe(true);
expect(config2.getString('string')).toBe('string');
expect(config3.getNumber('number')).toBe(42);
expect(
config.getOptionalConfigArray('nestlings')![0].getBoolean('boolean'),
).toBe(true);
}
function expectInvalidValues(config: ConfigReader) {
expect(() => config.getBoolean('string')).toThrow(
"Invalid type in config for key 'string' in 'ctx', got string, wanted boolean",
);
expect(() => config.getNumber('string')).toThrow(
'Invalid type in config for key string, got string, wanted number',
"Invalid type in config for key 'string' in 'ctx', got string, wanted number",
);
expect(() => config.getString('one')).toThrow(
'Invalid type in config for key one, got number, wanted string',
"Invalid type in config for key 'one' in 'ctx', got number, wanted string",
);
expect(() => config.getNumber('true')).toThrow(
'Invalid type in config for key true, got boolean, wanted number',
"Invalid type in config for key 'true' in 'ctx', got boolean, wanted number",
);
expect(() => config.getStringArray('null')).toThrow(
'Invalid type in config for key null, got null, wanted string-array',
"Invalid type in config for key 'null' in 'ctx', got null, wanted string-array",
);
expect(() => config.getString('emptyString')).toThrow(
'Invalid type in config for key emptyString, got empty-string, wanted string',
"Invalid type in config for key 'emptyString' in 'ctx', got empty-string, wanted string",
);
expect(() => config.getStringArray('badStrings')).toThrow(
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
"Invalid type in config for key 'badStrings[1]' in 'ctx', got empty-string, wanted string",
);
expect(() => config.getStringArray('worseStrings')).toThrow(
'Invalid type in config for key worseStrings[1], got number, wanted string',
"Invalid type in config for key 'worseStrings[1]' in 'ctx', got number, wanted string",
);
expect(() => config.getStringArray('worstStrings')).toThrow(
'Invalid type in config for key worstStrings[2], got object, wanted string',
"Invalid type in config for key 'worstStrings[2]' in 'ctx', got object, wanted string",
);
expect(() => config.getConfig('one')).toThrow(
'Invalid type in config for key one, got number, wanted object',
"Invalid type in config for key 'one' in 'ctx', got number, wanted object",
);
expect(() => config.getConfigArray('one')).toThrow(
'Invalid type in config for key one, got number, wanted object-array',
"Invalid type in config for key 'one' in 'ctx', got number, wanted object-array",
);
expect(() => config.getBoolean('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getNumber('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getString('missing')).toThrow(
"Missing required config value at 'missing'",
);
expect(() => config.getStringArray('missing')).toThrow(
"Missing required config value at 'missing'",
);
}
const CTX = 'ctx';
describe('ConfigReader', () => {
it('should read empty config with valid keys', () => {
const config = new ConfigReader({});
expect(config.getString('x')).toBeUndefined();
expect(config.getString('x_x')).toBeUndefined();
expect(config.getString('x-X')).toBeUndefined();
expect(config.getString('x0')).toBeUndefined();
expect(config.getString('X-x2')).toBeUndefined();
expect(config.getString('x0_x0')).toBeUndefined();
expect(config.getString('x_x-x_x')).toBeUndefined();
const config = new ConfigReader({}, CTX);
expect(config.keys()).toEqual([]);
expect(config.getOptionalString('x')).toBeUndefined();
expect(config.getOptionalString('x_x')).toBeUndefined();
expect(config.getOptionalString('x-X')).toBeUndefined();
expect(config.getOptionalString('x0')).toBeUndefined();
expect(config.getOptionalString('X-x2')).toBeUndefined();
expect(config.getOptionalString('x0_x0')).toBeUndefined();
expect(config.getOptionalString('x_x-x_x')).toBeUndefined();
expect(
new ConfigReader(undefined, CTX).getOptionalString('x'),
).toBeUndefined();
});
it('should throw on invalid keys', () => {
const config = new ConfigReader({});
const config = new ConfigReader({}, CTX);
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
@@ -119,35 +159,39 @@ describe('ConfigReader', () => {
expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a._')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/);
expect(() => new ConfigReader(undefined, CTX).getString('.')).toThrow(
/^Invalid config key/,
);
});
it('should read valid values', () => {
const config = new ConfigReader(DATA);
const config = new ConfigReader(DATA, CTX);
expectValidValues(config);
});
it('should fail to read invalid values', () => {
const config = new ConfigReader(DATA);
const config = new ConfigReader(DATA, CTX);
expectInvalidValues(config);
});
});
describe('ConfigReader with fallback', () => {
it('should behave as if without fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
expect(config.getString('x')).toBeUndefined();
const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX));
expect(config.getOptionalString('x')).toBeUndefined();
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
});
it('should read values from itself', () => {
const config = new ConfigReader(DATA, new ConfigReader({}));
const config = new ConfigReader(DATA, CTX, new ConfigReader({}, CTX));
expectValidValues(config);
expectInvalidValues(config);
});
it('should read values from a fallback', () => {
const config = new ConfigReader({}, new ConfigReader(DATA));
const config = new ConfigReader({}, CTX, new ConfigReader(DATA, CTX));
expectValidValues(config);
expectInvalidValues(config);
});
@@ -155,12 +199,92 @@ describe('ConfigReader with fallback', () => {
it('should read values from multiple levels of fallbacks', () => {
const config = new ConfigReader(
{},
new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))),
CTX,
new ConfigReader(
{},
CTX,
new ConfigReader({}, CTX, new ConfigReader(DATA, CTX)),
),
);
expectValidValues(config);
expectInvalidValues(config);
});
it('should show error with correct context', () => {
const config = ConfigReader.fromConfigs([
{
data: {
c: true,
},
context: 'x',
},
{
data: {
b: true,
c: true,
nested1: {
a: true,
},
badBefore: true,
badAfter: {
a: true,
},
},
context: 'y',
},
{
data: {
a: true,
b: true,
c: true,
nested1: {
a: true,
b: true,
},
badBefore: {
a: true,
},
badAfter: true,
},
context: 'z',
},
]);
expect(() => config.getNumber('a')).toThrow(
"Invalid type in config for key 'a' in 'z', got boolean, wanted number",
);
expect(() => config.getNumber('b')).toThrow(
"Invalid type in config for key 'b' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('c')).toThrow(
"Invalid type in config for key 'c' in 'x', got boolean, wanted number",
);
expect(() => config.getNumber('nested1.a')).toThrow(
"Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('nested1.b')).toThrow(
"Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number",
);
expect(() => config.getConfig('nested1').getNumber('a')).toThrow(
"Invalid type in config for key 'nested1.a' in 'y', got boolean, wanted number",
);
expect(() => config.getConfig('nested1').getNumber('b')).toThrow(
"Invalid type in config for key 'nested1.b' in 'z', got boolean, wanted number",
);
expect(() => config.getNumber('badBefore.a')).toThrow(
"Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object",
);
expect(() => config.getNumber('badBefore.b')).toThrow(
"Invalid type in config for key 'badBefore' in 'y', got boolean, wanted object",
);
expect(() => config.getNumber('badAfter.a')).toThrow(
"Invalid type in config for key 'badAfter.a' in 'y', got boolean, wanted number",
);
expect(() => config.getNumber('badAfter.b')).toThrow(
"Invalid type in config for key 'badAfter' in 'z', got boolean, wanted object",
);
});
it('should read merged objects', () => {
const a = {
merged: {
@@ -181,7 +305,18 @@ describe('ConfigReader with fallback', () => {
},
};
const config = new ConfigReader(a, new ConfigReader(b));
const config = new ConfigReader(a, CTX, new ConfigReader(b, CTX));
expect(config.keys()).toEqual(['merged']);
expect(config.getConfig('merged').keys()).toEqual([
'x',
'z',
'arr',
'config',
'configs',
'y',
]);
expect(config.getConfig('merged.config').keys()).toEqual(['d', 'e']);
expect(config.getString('merged.x')).toBe('x');
expect(config.getString('merged.y')).toBe('y');
@@ -206,12 +341,19 @@ describe('ConfigReader with fallback', () => {
'a',
'b',
]);
expect(() => config.getConfig('merged').getStringArray('x')).toThrow(
"Invalid type in config for key 'merged.x' in 'ctx', got string, wanted string-array",
);
// Config arrays aren't merged either
expect(config.getConfigArray('merged.configs').length).toBe(1);
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a');
expect(() =>
config.getConfigArray('merged.configs')[0].getString('missing'),
).toThrow("Missing required config value at 'merged.configs[0].missing'");
expect(
config.getConfigArray('merged.configs')[0].getString('b'),
config.getConfigArray('merged.configs')[0].getOptionalString('b'),
).toBeUndefined();
// Config arrays aren't merged either
@@ -220,7 +362,173 @@ describe('ConfigReader with fallback', () => {
config.getConfig('merged').getConfigArray('configs')[0].getString('a'),
).toBe('a');
expect(
config.getConfig('merged').getConfigArray('configs')[0].getString('b'),
config
.getConfig('merged')
.getConfigArray('configs')[0]
.getOptionalString('b'),
).toBeUndefined();
});
});
describe('ConfigReader.get()', () => {
const config1 = {
a: {
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
b: {
x: 'x1',
y: ['y11'],
},
};
const config2 = {
b: {
x: 'x2',
y: ['y21', 'y22'],
z: 'z2',
},
c: {
c1: {
c2: 'c2',
},
},
};
const config3 = {
c: {
c1: 'c1',
},
};
const configs = [
{
data: config1,
context: '1',
},
{
data: config2,
context: '2',
},
{
data: config3,
context: '3',
},
];
it('should be able to select sub-configs', () => {
expect(new ConfigReader(config1).get('a')).toEqual(config1.a);
expect(new ConfigReader(config1).get('b')).toEqual(config1.b);
expect(new ConfigReader(config2).get('b')).toEqual(config2.b);
expect(new ConfigReader(config2).get('c')).toEqual(config2.c);
expect(new ConfigReader(config3).get('c')).toEqual(config3.c);
expect(new ConfigReader(config2).get('c.c1')).toEqual(config2.c.c1);
expect(new ConfigReader(config2).getConfig('c').get('c1')).toEqual(
config2.c.c1,
);
});
it('should merge in fallback configs', () => {
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual(
{
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual(
{
x: 'x1',
y: ['y11'],
z: 'z2',
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual(
{
c1: {
c2: 'c2',
},
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('a')).toEqual(
{
x: 'x1',
y: ['y11', 'y12', 'y13'],
z: false,
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('b')).toEqual(
{
x: 'x1',
y: ['y11'],
z: 'z2',
},
);
expect(ConfigReader.fromConfigs([configs[0], configs[1]]).get('c')).toEqual(
{
c1: {
c2: 'c2',
},
},
);
expect(
ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('b'),
).toEqual({
x: 'x2',
y: ['y21', 'y22'],
z: 'z2',
});
expect(
ConfigReader.fromConfigs([configs[2], configs[1]]).getOptional('c'),
).toEqual({
c1: 'c1',
});
});
it('should not merge non-objects', () => {
const config = ConfigReader.fromConfigs([
{
data: {
a: ['1', '2'],
c: [],
d: {
x: 'x',
},
e: ['3'],
f: 'foo',
g: { z: 'z' },
h: {
a: 'a1',
c: 'c1',
},
},
context: '1',
},
{
data: {
a: ['x', 'y', 'z'],
b: ['1'],
c: ['1'],
d: ['2'],
e: {
y: 'y',
},
f: { x: 'x' },
g: 'bar',
h: {
a: 'a2',
b: 'b2',
},
},
context: '2',
},
]);
expect(config.get('a')).toEqual(['1', '2']);
expect(config.get('b')).toEqual(['1']);
expect(config.get('c')).toEqual([]);
expect(config.get('d')).toEqual({ x: 'x' });
expect(config.get('e')).toEqual(['3']);
expect(config.get('f')).toEqual('foo');
expect(config.get('g')).toEqual({ z: 'z' });
expect(config.get('h')).toEqual({ a: 'a1', b: 'b2', c: 'c1' });
});
});
+151 -25
View File
@@ -15,6 +15,8 @@
*/
import { AppConfig, Config, JsonValue, JsonObject } from './types';
import cloneDeep from 'lodash/cloneDeep';
import mergeWith from 'lodash/mergeWith';
// 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;
@@ -39,43 +41,106 @@ function typeOf(value: JsonValue | undefined): string {
return type;
}
export class ConfigReader implements Config {
private static readonly nullReader = new ConfigReader({});
// Separate out a couple of common error messages to reduce bundle size.
const errors = {
type(key: string, context: string, typeName: string, expected: string) {
return `Invalid type in config for key '${key}' in '${context}', got ${typeName}, wanted ${expected}`;
},
missing(key: string) {
return `Missing required config value at '${key}'`;
},
};
export class ConfigReader implements Config {
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
return new ConfigReader(undefined);
}
// 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<ConfigReader>((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined!);
return configs.reduceRight<ConfigReader>(
(previousReader, { data, context }) => {
return new ConfigReader(data, context, previousReader);
},
undefined!,
);
}
constructor(
private readonly data: JsonObject,
private readonly data: JsonObject | undefined,
private readonly context: string = 'empty-config',
private readonly fallback?: ConfigReader,
private readonly prefix: string = '',
) {}
getConfig(key: string): ConfigReader {
keys(): string[] {
const localKeys = this.data ? Object.keys(this.data) : [];
const fallbackKeys = this.fallback?.keys() ?? [];
return [...new Set([...localKeys, ...fallbackKeys])];
}
get(key: string): JsonValue {
const value = this.getOptional(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptional(key: string): JsonValue | undefined {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getConfig(key);
const fallbackValue = this.fallback?.getOptional(key);
if (value === undefined) {
return fallbackValue;
} else if (fallbackValue === undefined) {
return value;
}
// Avoid merging arrays and primitive values, since that's how merging works for other
// methods for reading config.
return mergeWith(
{},
{ value: cloneDeep(fallbackValue) },
{ value },
(into, from) => (!isObject(from) || !isObject(into) ? from : undefined),
).value;
}
getConfig(key: string): ConfigReader {
const value = this.getOptionalConfig(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalConfig(key: string): ConfigReader | undefined {
const value = this.readValue(key);
const fallbackConfig = this.fallback?.getOptionalConfig(key);
const prefix = this.fullKey(key);
if (isObject(value)) {
return new ConfigReader(value, fallbackConfig);
return new ConfigReader(value, this.context, fallbackConfig, prefix);
}
if (value !== undefined) {
throw new TypeError(
`Invalid type in config for key ${key}, got ${typeOf(
value,
)}, wanted object`,
errors.type(this.fullKey(key), this.context, typeOf(value), 'object'),
);
}
return fallbackConfig ?? ConfigReader.nullReader;
return fallbackConfig;
}
getConfigArray(key: string): ConfigReader[] {
const value = this.getOptionalConfigArray(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalConfigArray(key: string): ConfigReader[] | undefined {
const configs = this.readConfigValue<JsonObject[]>(key, values => {
if (!Array.isArray(values)) {
return { expected: 'object-array' };
@@ -89,24 +154,60 @@ export class ConfigReader implements Config {
return true;
});
return (configs ?? []).map(obj => new ConfigReader(obj));
if (!configs) {
return undefined;
}
return configs.map(
(obj, index) =>
new ConfigReader(
obj,
this.context,
undefined,
this.fullKey(`${key}[${index}]`),
),
);
}
getNumber(key: string): number | undefined {
getNumber(key: string): number {
const value = this.getOptionalNumber(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalNumber(key: string): number | undefined {
return this.readConfigValue(
key,
value => typeof value === 'number' || { expected: 'number' },
);
}
getBoolean(key: string): boolean | undefined {
getBoolean(key: string): boolean {
const value = this.getOptionalBoolean(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalBoolean(key: string): boolean | undefined {
return this.readConfigValue(
key,
value => typeof value === 'boolean' || { expected: 'boolean' },
);
}
getString(key: string): string | undefined {
getString(key: string): string {
const value = this.getOptionalString(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalString(key: string): string | undefined {
return this.readConfigValue(
key,
value =>
@@ -114,7 +215,15 @@ export class ConfigReader implements Config {
);
}
getStringArray(key: string): string[] | undefined {
getStringArray(key: string): string[] {
const value = this.getOptionalStringArray(key);
if (value === undefined) {
throw new Error(errors.missing(this.fullKey(key)));
}
return value;
}
getOptionalStringArray(key: string): string[] | undefined {
return this.readConfigValue(key, values => {
if (!Array.isArray(values)) {
return { expected: 'string-array' };
@@ -128,6 +237,10 @@ export class ConfigReader implements Config {
});
}
private fullKey(key: string): string {
return `${this.prefix}${this.prefix ? '.' : ''}${key}`;
}
private readConfigValue<T extends JsonValue>(
key: string,
validate: (
@@ -147,9 +260,13 @@ export class ConfigReader implements Config {
value: theValue = value,
expected,
} = result;
const typeName = typeOf(theValue);
throw new TypeError(
`Invalid type in config for key ${keyName}, got ${typeName}, wanted ${expected}`,
errors.type(
this.fullKey(keyName),
this.context,
typeOf(theValue),
expected,
),
);
}
}
@@ -159,16 +276,25 @@ export class ConfigReader implements Config {
private readValue(key: string): JsonValue | undefined {
const parts = key.split('.');
let value: JsonValue | undefined = this.data;
for (const part of parts) {
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
throw new TypeError(`Invalid config key '${key}'`);
}
}
if (this.data === undefined) {
return undefined;
}
let value: JsonValue | undefined = this.data;
for (const [index, part] of parts.entries()) {
if (isObject(value)) {
value = value[part];
} else {
value = undefined;
} else if (value !== undefined) {
const badKey = this.fullKey(parts.slice(0, index).join('.'));
throw new TypeError(
errors.type(badKey, this.context, typeOf(value), 'object'),
);
}
}
+20 -6
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export type JsonObject = { [key in string]: JsonValue };
export type JsonObject = { [key in string]?: JsonValue };
export type JsonArray = JsonValue[];
export type JsonValue =
| JsonObject
@@ -24,18 +24,32 @@ export type JsonValue =
| boolean
| null;
export type AppConfig = JsonObject;
export type AppConfig = {
context: string;
data: JsonObject;
};
export type Config = {
keys(): string[];
get(key: string): JsonValue;
getOptional(key: string): JsonValue | undefined;
getConfig(key: string): Config;
getOptionalConfig(key: string): Config | undefined;
getConfigArray(key: string): Config[];
getOptionalConfigArray(key: string): Config[] | undefined;
getNumber(key: string): number | undefined;
getNumber(key: string): number;
getOptionalNumber(key: string): number | undefined;
getBoolean(key: string): boolean | undefined;
getBoolean(key: string): boolean;
getOptionalBoolean(key: string): boolean | undefined;
getString(key: string): string | undefined;
getString(key: string): string;
getOptionalString(key: string): string | undefined;
getStringArray(key: string): string[] | undefined;
getStringArray(key: string): string[];
getOptionalStringArray(key: string): string[] | undefined;
};
+1 -2
View File
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -14,20 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
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;
};
import { Config } from '@backstage/config';
// Using interface to make the ConfigApi name show up in docs
export interface ConfigApi extends Config {}
@@ -35,7 +35,7 @@ export type IdentityApi = {
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): string | undefined;
getIdToken(): Promise<string | undefined>;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
+23 -1
View File
@@ -157,25 +157,47 @@ export type ProfileInfoOptions = {
optional?: boolean;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
};
/**
* Profile information of the user from an auth provider.
*/
export type ProfileInfo = {
provider: string;
/**
* Email ID.
*/
email: string;
/**
* Display name that can be presented to the user.
*/
name?: string;
/**
* URL to an avatar image of the user.
*/
picture?: string;
};
/**
* Session state values passed to subscribers of the SessionStateApi.
*/
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
/**
* This API provides access to an sessionState$ observable which provides an update when the
* user performs a sign in or sign out from an auth provider.
*/
export type SessionStateApi = {
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
+1 -3
View File
@@ -282,7 +282,7 @@ export class PrivateAppImpl implements BackstageApp {
const configApi = useApi(configApiRef);
let { pathname } = new URL(
configApi.getString('app.baseUrl') ?? '/',
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
if (pathname.endsWith('/')) {
@@ -293,8 +293,6 @@ export class PrivateAppImpl implements BackstageApp {
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
idToken: undefined,
logout: async () => {},
});
return (
+4 -4
View File
@@ -24,7 +24,7 @@ import { SignInResult } from './types';
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private idToken?: string;
private idTokenFunc?: () => Promise<string>;
private logoutFunc?: () => Promise<void>;
getUserId(): string {
@@ -36,13 +36,13 @@ export class AppIdentity implements IdentityApi {
return this.userId!;
}
getIdToken(): string | undefined {
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idToken;
return this.idTokenFunc?.();
}
async logout(): Promise<void> {
@@ -64,7 +64,7 @@ export class AppIdentity implements IdentityApi {
}
this.hasIdentity = true;
this.userId = result.userId;
this.idToken = result.idToken;
this.idTokenFunc = result.getIdToken;
this.logoutFunc = result.logout;
}
}
+2 -2
View File
@@ -32,9 +32,9 @@ export type SignInResult = {
*/
userId: string;
/**
* ID token that will be returned by the IdentityApi
* Function used to retrieve an ID token for the signed in user.
*/
idToken?: string;
getIdToken?: () => Promise<string>;
/**
* Logout handler that will be called if the user requests a logout.
*/
+2 -3
View File
@@ -18,8 +18,7 @@
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs types,esm",
@@ -46,7 +45,7 @@
"rc-progress": "^3.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "6.0.0",
"react-helmet": "6.1.0",
"react-hook-form": "^5.7.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
@@ -15,6 +15,7 @@
*/
import { defaultConfigLoader } from './createApp';
import { AppConfig } from '@backstage/config';
describe('defaultConfigLoader', () => {
afterEach(() => {
@@ -24,24 +25,33 @@ describe('defaultConfigLoader', () => {
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
value: [
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
] as AppConfig[],
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
expect(configs).toEqual([
{ data: { my: 'config' }, context: 'a' },
{ data: { my: 'override-config' }, context: 'b' },
]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
value: [
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
] as AppConfig[],
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
{ data: { my: 'runtime-config' }, context: 'env' },
{ data: { my: 'override-config' }, context: 'a' },
{ data: { my: 'config' }, context: 'b' },
]);
});
@@ -64,7 +74,7 @@ describe('defaultConfigLoader', () => {
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
value: [{ data: { my: 'config' }, context: 'a' }] as AppConfig[],
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
+4 -3
View File
@@ -25,9 +25,9 @@ import privateExports, {
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { Progress } from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig } from '@backstage/config';
import { AppConfig, JsonObject } from '@backstage/config';
const { PrivateAppImpl } = privateExports;
@@ -59,7 +59,8 @@ export const defaultConfigLoader: AppConfigLoader = async (
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
const data = JSON.parse(runtimeConfigJson) as JsonObject;
configs.unshift({ data, context: 'env' });
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './AlertDisplay';
export { AlertDisplay } from './AlertDisplay';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
const JAVASCRIPT = `const greeting = "Hello";
const world = "World";
@@ -31,7 +31,7 @@ const defaultProps = {
showLineNumbers: false,
};
const CodeSnippet: FC<Props> = props => {
export const CodeSnippet: FC<Props> = props => {
const { text, language, showLineNumbers } = {
...defaultProps,
...props,
@@ -57,5 +57,3 @@ CodeSnippet.propTypes = {
language: PropTypes.string.isRequired,
showLineNumbers: PropTypes.bool,
};
export default CodeSnippet;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CodeSnippet';
export { CodeSnippet } from './CodeSnippet';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CopyTextButton from '.';
import { CopyTextButton } from '.';
export default {
title: 'CopyTextButton',
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CopyTextButton from './CopyTextButton';
import { CopyTextButton } from './CopyTextButton';
import {
ApiRegistry,
errorApiRef,
@@ -56,7 +56,7 @@ const defaultProps = {
tooltipText: 'Text copied to clipboard',
};
const CopyTextButton: FC<Props> = props => {
export const CopyTextButton: FC<Props> = props => {
const { text, tooltipDelay, tooltipText } = {
...defaultProps,
...props,
@@ -110,5 +110,3 @@ CopyTextButton.propTypes = {
tooltipDelay: PropTypes.number,
tooltipText: PropTypes.string,
};
export default CopyTextButton;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CopyTextButton';
export { CopyTextButton } from './CopyTextButton';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import { Link, Typography } from '@material-ui/core';
import {
ApiProvider,
@@ -17,7 +17,7 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import {
ApiRegistry,
ApiProvider,
@@ -59,7 +59,7 @@ type Props = {
id: string;
};
const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
const classes = useStyles();
const storageApi = useApi(storageApiRef);
const notificationsStore = storageApi.forBucket('notifications');
@@ -111,5 +111,3 @@ const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
</Snackbar>
);
};
export default DismissableBanner;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './DismissableBanner';
export { DismissableBanner } from './DismissableBanner';
@@ -0,0 +1,17 @@
/*
* 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 { FeatureCalloutCircular } from './FeatureCalloutCircular';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
const cardContentStyle = { height: 0, padding: 150, margin: 20 };
const containerStyle = { width: 800, height: 400, margin: 20 };
@@ -17,7 +17,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
describe('<HorizontalScrollGrid />', () => {
@@ -181,7 +181,7 @@ function useSmoothScroll(
return setScrollTarget;
}
const HorizontalScrollGrid: FC<Props> = props => {
export const HorizontalScrollGrid: FC<Props> = props => {
const {
scrollStep = 100,
scrollSpeed = 50,
@@ -245,5 +245,3 @@ const HorizontalScrollGrid: FC<Props> = props => {
</div>
);
};
export default HorizontalScrollGrid;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './HorizontalScrollGrid';
export { HorizontalScrollGrid } from './HorizontalScrollGrid';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import Progress from '.';
import { Progress } from '.';
export default {
title: 'Progress',
@@ -17,7 +17,7 @@
import React, { FC, useState, useEffect } from 'react';
import { LinearProgress, LinearProgressProps } from '@material-ui/core';
const Progress: FC<LinearProgressProps> = props => {
export const Progress: FC<LinearProgressProps> = props => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
@@ -31,5 +31,3 @@ const Progress: FC<LinearProgressProps> = props => {
<div style={{ display: 'none' }} data-testid="progress" />
);
};
export default Progress;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './Progress';
export { Progress } from './Progress';
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CircleProgress, { getProgressColor } from './CircleProgress';
import { CircleProgress, getProgressColor } from './CircleProgress';
describe('<CircleProgress />', () => {
it('renders without exploding', () => {
@@ -77,7 +77,7 @@ export function getProgressColor(
return palette.status.ok;
}
const CircleProgress: FC<Props> = props => {
export const CircleProgress: FC<Props> = props => {
const classes = useStyles(props);
const theme = useTheme<BackstageTheme>();
const { value, fractional, inverse, unit, max } = {
@@ -104,5 +104,3 @@ const CircleProgress: FC<Props> = props => {
</div>
);
};
export default CircleProgress;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import HorizontalProgress from './HorizontalProgress';
import { HorizontalProgress } from './HorizontalProgress';
const containerStyle = { width: 300 };
@@ -28,7 +28,7 @@ type Props = {
value: number;
};
const HorizontalProgress: FC<Props> = ({ value }) => {
export const HorizontalProgress: FC<Props> = ({ value }) => {
const theme = useTheme<BackstageTheme>();
if (isNaN(value)) {
return null;
@@ -49,5 +49,3 @@ const HorizontalProgress: FC<Props> = ({ value }) => {
</Tooltip>
);
};
export default HorizontalProgress;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import ProgressCard from './ProgressCard';
import { ProgressCard } from './ProgressCard';
import { Grid } from '@material-ui/core';
const linkInfo = { title: 'Go to XYZ Location', link: '#' };
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import ProgressCard from './ProgressCard';
import { ProgressCard } from './ProgressCard';
const minProps = { title: 'Tingle upgrade', progress: 0.12 };
@@ -18,7 +18,7 @@ import React, { FC } from 'react';
import { makeStyles } from '@material-ui/core';
import { InfoCard } from '../../layout/InfoCard';
import { BottomLinkProps } from '../../layout/BottomLink';
import CircleProgress from './CircleProgress';
import { CircleProgress } from './CircleProgress';
type Props = {
title: string;
@@ -36,7 +36,7 @@ const useStyles = makeStyles({
},
});
const ProgressCard: FC<Props> = props => {
export const ProgressCard: FC<Props> = props => {
const classes = useStyles(props);
const { title, subheader, progress, deepLink, variant } = props;
@@ -53,5 +53,3 @@ const ProgressCard: FC<Props> = props => {
</div>
);
};
export default ProgressCard;
@@ -0,0 +1,19 @@
/*
* 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 { ProgressCard } from './ProgressCard';
export { CircleProgress } from './CircleProgress';
export { HorizontalProgress } from './HorizontalProgress';
@@ -16,8 +16,8 @@
import React from 'react';
import { render, fireEvent, within } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import Stepper from './SimpleStepper';
import Step from './SimpleStepperStep';
import { SimpleStepper as Stepper } from './SimpleStepper';
import { SimpleStepperStep as Step } from './SimpleStepperStep';
const getTextInSlide = (rendered: any, index: number) =>
within(rendered.getByTestId(`step${index}`)).getByText;
@@ -40,13 +40,17 @@ export interface StepperProps {
onStepChange?: (prevIndex: number, nextIndex: number) => void;
}
const Stepper: FC<StepperProps> = ({ children, elevated, onStepChange }) => {
export const SimpleStepper: FC<StepperProps> = ({
children,
elevated,
onStepChange,
}) => {
const [stepIndex, setStepIndex] = useState<number>(0);
const [stepHistory, setStepHistory] = useState<number[]>([0]);
const steps: React.ReactNode[] = [];
let endStep;
Children.forEach(children, (child) => {
Children.forEach(children, child => {
if (isValidElement(child)) {
if (child.props.end) {
endStep = child;
@@ -80,5 +84,3 @@ const Stepper: FC<StepperProps> = ({ children, elevated, onStepChange }) => {
</>
);
};
export default Stepper;
@@ -18,7 +18,7 @@ import { Button, makeStyles } from '@material-ui/core';
import { StepActions } from './SimpleStepperStep';
import { VerticalStepperContext } from './SimpleStepper';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
root: {
marginTop: theme.spacing(3),
'& button': {
@@ -71,7 +71,7 @@ export type SimpleStepperFooterProps = {
children?: ReactNode;
};
const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
export const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
actions = {},
children,
}) => {
@@ -145,5 +145,3 @@ const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
</div>
);
};
export default SimpleStepperFooter;
@@ -21,9 +21,9 @@ import {
Typography,
makeStyles,
} from '@material-ui/core';
import SimpleStepperFooter from './SimpleStepperFooter';
import { SimpleStepperFooter } from './SimpleStepperFooter';
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles(theme => ({
end: {
padding: theme.spacing(3),
},
@@ -53,7 +53,7 @@ export type StepProps = {
actions?: StepActions;
};
const Step: FC<StepProps> = ({
export const SimpleStepperStep: FC<StepProps> = ({
title,
children,
end,
@@ -82,5 +82,3 @@ const Step: FC<StepProps> = ({
</MuiStep>
);
};
export default Step;
@@ -14,7 +14,5 @@
* limitations under the License.
*/
import SimpleStepper from './SimpleStepper';
import SimpleStepperStep from './SimpleStepperStep';
export { SimpleStepper, SimpleStepperStep };
export { SimpleStepper } from './SimpleStepper';
export { SimpleStepperStep } from './SimpleStepperStep';
@@ -23,7 +23,7 @@ import {
StatusRunning,
StatusWarning,
} from './Status';
import Table from '../Table';
import { Table } from '../Table';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -16,7 +16,7 @@
import React, { FC } from 'react';
import { InfoCard } from '../../layout/InfoCard';
import { Grid } from '@material-ui/core';
import StructuredMetadataTable from '.';
import { StructuredMetadataTable } from './StructuredMetadataTable';
const cardContentStyle = { heightX: 200, width: 500 };
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import StructuredMetadataTable from './StructuredMetadataTable';
import { StructuredMetadataTable } from './StructuredMetadataTable';
import { startCase } from 'lodash';
describe('<StructuredMetadataTable />', () => {
@@ -56,7 +56,7 @@ function renderMap(
nested?: boolean,
options?: any,
) {
const values = Object.keys(map).map((key) => {
const values = Object.keys(map).map(key => {
const value = toValue(map[key], true);
const fmtKey =
options && options.titleFormat
@@ -98,7 +98,7 @@ function toValue(
}
function mapToItems(info: { [key: string]: string }, options: any) {
return Object.keys(info).map((key) => (
return Object.keys(info).map(key => (
<TableItem key={key} title={key} value={info[key]} options={options} />
));
}
@@ -147,7 +147,8 @@ interface ComponentProps {
dense?: boolean;
options?: any;
}
export default class StructuredMetadataTable extends Component<ComponentProps> {
export class StructuredMetadataTable extends Component<ComponentProps> {
render() {
const { metadata, dense, options } = this.props;
const metadataItems = mapToItems(metadata, options || {});
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './StructuredMetadataTable';
export { StructuredMetadataTable } from './StructuredMetadataTable';
@@ -49,7 +49,7 @@ const useStyles = makeStyles(theme => ({
},
}));
const SupportButton: FC<Props> = ({
export const SupportButton: FC<Props> = ({
slackChannel = '#backstage',
email = [],
children,
@@ -155,5 +155,3 @@ const SupportButton: FC<Props> = ({
</Fragment>
);
};
export default SupportButton;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './SupportButton';
export { SupportButton } from './SupportButton';
@@ -33,7 +33,7 @@ type SubvalueCellProps = {
subvalue: React.ReactNode;
};
const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
export const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
const classes = useSubvalueCellStyles();
return (
@@ -43,5 +43,3 @@ const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
</>
);
};
export default SubvalueCell;
@@ -15,7 +15,7 @@
*/
import React from 'react';
import Table, { SubvalueCell, TableColumn } from './';
import { Table, SubvalueCell, TableColumn } from './';
export default {
title: 'Table',
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import Table from './';
import { Table } from './Table';
const minProps = {
columns: [
+1 -3
View File
@@ -162,7 +162,7 @@ export interface TableProps extends MaterialTableProps<{}> {
subtitle?: string;
}
const Table: FC<TableProps> = ({
export const Table: FC<TableProps> = ({
columns,
options,
title,
@@ -212,5 +212,3 @@ const Table: FC<TableProps> = ({
/>
);
};
export default Table;
+2 -2
View File
@@ -14,6 +14,6 @@
* limitations under the License.
*/
export { default } from './Table';
export { Table } from './Table';
export type { TableColumn } from './Table';
export { default as SubvalueCell } from './SubvalueCell';
export { SubvalueCell } from './SubvalueCell';
+1 -1
View File
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { Tabs as default } from './Tabs';
export { Tabs } from './Tabs';
@@ -15,8 +15,8 @@
*/
import React from 'react';
import TrendLine from '.';
import Table from '../Table';
import { Table } from '../Table';
import { TrendLine } from './TrendLine';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -19,7 +19,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import TrendLine from '.';
import { TrendLine } from './TrendLine';
describe('TrendLine', () => {
describe('when no data is present', () => {
@@ -27,7 +27,7 @@ function color(data: number[], theme: BackstageTheme): string | undefined {
return theme.palette.status.error;
}
const Trendline: FC<SparklinesProps & { title?: string }> = props => {
export const TrendLine: FC<SparklinesProps & { title?: string }> = props => {
const theme = useTheme<BackstageTheme>();
if (!props.data) return null;
@@ -38,5 +38,3 @@ const Trendline: FC<SparklinesProps & { title?: string }> = props => {
</Sparklines>
);
};
export default Trendline;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './TrendLine';
export { TrendLine } from './TrendLine';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import WarningPanel from '.';
import { WarningPanel } from './WarningPanel';
import { Link, Button } from '@material-ui/core';
export default {
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import WarningPanel from './WarningPanel';
import { WarningPanel } from './WarningPanel';
const minProps = { title: 'Mock title', message: 'Some more info' };
@@ -19,7 +19,7 @@ import { Typography, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
const useErrorOutlineStyles = makeStyles<BackstageTheme>((theme) => ({
const useErrorOutlineStyles = makeStyles<BackstageTheme>(theme => ({
root: {
marginRight: theme.spacing(1),
fill: theme.palette.warningText,
@@ -30,7 +30,7 @@ const ErrorOutlineStyled = () => {
return <ErrorOutline classes={classes} />;
};
const useStyles = makeStyles<BackstageTheme>((theme) => ({
const useStyles = makeStyles<BackstageTheme>(theme => ({
message: {
display: 'flex',
flexDirection: 'column',
@@ -62,7 +62,7 @@ type Props = {
title?: string;
};
const WarningPanel: FC<Props> = (props) => {
export const WarningPanel: FC<Props> = props => {
const classes = useStyles(props);
const { title, message, children } = props;
return (
@@ -82,5 +82,3 @@ const WarningPanel: FC<Props> = (props) => {
</div>
);
};
export default WarningPanel;
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { default } from './WarningPanel';
export { WarningPanel } from './WarningPanel';
+36
View File
@@ -0,0 +1,36 @@
/*
* 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 * from './AlertDisplay';
export * from './Button';
export * from './CodeSnippet';
export * from './CopyTextButton';
export * from './DismissableBanner';
export * from './FeatureDiscovery';
export * from './HorizontalScrollGrid';
export * from './Lifecycle';
export * from './Link';
export * from './OAuthRequestDialog';
export * from './Progress';
export * from './ProgressBars';
export * from './SimpleStepper';
export * from './Status';
export * from './StructuredMetadataTable';
export * from './SupportButton';
export * from './Table';
export * from './Tabs';
export * from './TrendLine';
export * from './WarningPanel';
+1 -24
View File
@@ -17,28 +17,5 @@
export * from '@backstage/core-api';
export * from './api-wrappers';
export * from './components';
export * from './layout';
export { default as CodeSnippet } from './components/CodeSnippet';
export { default as DismissableBanner } from './components/DismissableBanner';
export { AlertDisplay } from './components/AlertDisplay';
export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid';
export { default as ProgressCard } from './components/ProgressBars/ProgressCard';
export { default as CircleProgress } from './components/ProgressBars/CircleProgress';
export { default as HorizontalProgress } from './components/ProgressBars/HorizontalProgress';
export { default as CopyTextButton } from './components/CopyTextButton';
export { default as Progress } from './components/Progress';
export * from './components/SimpleStepper';
export { OAuthRequestDialog } from './components/OAuthRequestDialog';
export { Lifecycle } from './components/Lifecycle';
export { default as SupportButton } from './components/SupportButton';
export { default as Table, SubvalueCell } from './components/Table';
export type { TableColumn } from './components/Table/Table';
export { default as StructuredMetadataTable } from './components/StructuredMetadataTable';
export { default as TrendLine } from './components/TrendLine';
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';
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';

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