Merge remote-tracking branch 'origin/master' into ndudnik/filter-by-identity
This commit is contained in:
@@ -116,3 +116,6 @@ dist
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Temporary change files created by Vim
|
||||
*.swp
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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' }] : [];
|
||||
}
|
||||
|
||||
@@ -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,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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 };
|
||||
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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
Reference in New Issue
Block a user