Merge branch 'master' of github.com:spotify/backstage into mob/register-unregister-components
This commit is contained in:
@@ -14,14 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createApp,
|
||||
AlertDisplay,
|
||||
OAuthRequestDialog,
|
||||
LoginPage,
|
||||
} from '@backstage/core';
|
||||
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import Root from './components/Root';
|
||||
import * as plugins from './plugins';
|
||||
import apis from './apis';
|
||||
@@ -29,6 +24,18 @@ import apis from './apis';
|
||||
const app = createApp({
|
||||
apis,
|
||||
plugins: Object.values(plugins),
|
||||
configLoader: async () => ({
|
||||
app: {
|
||||
title: 'Backstage Example App',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
backend: {
|
||||
baseUrl: 'http://localhost:7000',
|
||||
},
|
||||
organization: {
|
||||
name: 'Spotify',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const AppProvider = app.getProvider();
|
||||
@@ -40,7 +47,6 @@ const App: FC<{}> = () => (
|
||||
<OAuthRequestDialog />
|
||||
<Router>
|
||||
<Root>
|
||||
<Route key="login" path="/login" component={LoginPage} exact />
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 { 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;
|
||||
};
|
||||
|
||||
// Using interface to make the ConfigApi name show up in docs
|
||||
export interface ConfigApi extends Config {}
|
||||
|
||||
export const configApiRef = createApiRef<ConfigApi>({
|
||||
id: 'core.config',
|
||||
description: 'Used to access runtime configuration',
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export * from './auth';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './FeatureFlagsApi';
|
||||
export * from './OAuthRequestApi';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from './ConfigReader';
|
||||
|
||||
const DATA = {
|
||||
zero: 0,
|
||||
one: 1,
|
||||
true: true,
|
||||
false: false,
|
||||
null: null,
|
||||
string: 'string',
|
||||
emptyString: '',
|
||||
strings: ['string1', 'string2'],
|
||||
badStrings: ['string1', ''],
|
||||
worseStrings: ['string1', 3] as string[],
|
||||
worstStrings: ['string1', 'string2', {}] as string[],
|
||||
nested: {
|
||||
one: 1,
|
||||
string: 'string',
|
||||
strings: ['string1', 'string2'],
|
||||
},
|
||||
nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[],
|
||||
};
|
||||
|
||||
function expectValidValues(config: ConfigReader) {
|
||||
expect(config.getNumber('zero')).toBe(0);
|
||||
expect(config.getNumber('one')).toBe(1);
|
||||
expect(config.getBoolean('true')).toBe(true);
|
||||
expect(config.getBoolean('false')).toBe(false);
|
||||
expect(config.getString('string')).toBe('string');
|
||||
expect(config.getStringArray('strings')).toEqual(['string1', 'string2']);
|
||||
expect(config.getConfig('nested').getNumber('one')).toBe(1);
|
||||
expect(config.getConfig('nested').getString('string')).toBe('string');
|
||||
expect(config.getConfig('nested').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);
|
||||
}
|
||||
|
||||
function expectInvalidValues(config: ConfigReader) {
|
||||
expect(() => config.getNumber('string')).toThrow(
|
||||
'Invalid type in config for key string, got string, wanted number',
|
||||
);
|
||||
expect(() => config.getString('one')).toThrow(
|
||||
'Invalid type in config for key one, got number, wanted string',
|
||||
);
|
||||
expect(() => config.getNumber('true')).toThrow(
|
||||
'Invalid type in config for key true, got boolean, wanted number',
|
||||
);
|
||||
expect(() => config.getStringArray('null')).toThrow(
|
||||
'Invalid type in config for key null, got null, wanted string-array',
|
||||
);
|
||||
expect(() => config.getString('emptyString')).toThrow(
|
||||
'Invalid type in config for key emptyString, got empty-string, wanted string',
|
||||
);
|
||||
expect(() => config.getStringArray('badStrings')).toThrow(
|
||||
'Invalid type in config for key badStrings[1], got empty-string, wanted string',
|
||||
);
|
||||
expect(() => config.getStringArray('worseStrings')).toThrow(
|
||||
'Invalid type in config for key worseStrings[1], got number, wanted string',
|
||||
);
|
||||
expect(() => config.getStringArray('worstStrings')).toThrow(
|
||||
'Invalid type in config for key worstStrings[2], got object, wanted string',
|
||||
);
|
||||
expect(() => config.getConfig('one')).toThrow(
|
||||
'Invalid type in config for key one, got number, wanted object',
|
||||
);
|
||||
expect(() => config.getConfigArray('one')).toThrow(
|
||||
'Invalid type in config for key one, got number, wanted object-array',
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should throw on invalid keys', () => {
|
||||
const config = new ConfigReader({});
|
||||
|
||||
expect(() => config.getString('.')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('0')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('(')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('z-_')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('-')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('.a')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('0.a')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('0a')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('a..a')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('a.')).toThrow(/^Invalid config key/);
|
||||
expect(() => config.getString('a...')).toThrow(/^Invalid config key/);
|
||||
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/);
|
||||
});
|
||||
|
||||
it('should read valid values', () => {
|
||||
const config = new ConfigReader(DATA);
|
||||
expectValidValues(config);
|
||||
});
|
||||
|
||||
it('should fail to read invalid values', () => {
|
||||
const config = new ConfigReader(DATA);
|
||||
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();
|
||||
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({}));
|
||||
expectValidValues(config);
|
||||
expectInvalidValues(config);
|
||||
});
|
||||
|
||||
it('should read values from a fallback', () => {
|
||||
const config = new ConfigReader({}, new ConfigReader(DATA));
|
||||
expectValidValues(config);
|
||||
expectInvalidValues(config);
|
||||
});
|
||||
|
||||
it('should read values from multiple levels of fallbacks', () => {
|
||||
const config = new ConfigReader(
|
||||
{},
|
||||
new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))),
|
||||
);
|
||||
expectValidValues(config);
|
||||
expectInvalidValues(config);
|
||||
});
|
||||
|
||||
it('should read merged objects', () => {
|
||||
const a = {
|
||||
merged: {
|
||||
x: 'x',
|
||||
z: 'z1',
|
||||
arr: ['a', 'b'],
|
||||
config: { d: 'd' },
|
||||
configs: [{ a: 'a' }],
|
||||
},
|
||||
};
|
||||
const b = {
|
||||
merged: {
|
||||
y: 'y',
|
||||
z: 'z2',
|
||||
arr: ['c'],
|
||||
config: { e: 'e' },
|
||||
configs: [{ b: 'b' }],
|
||||
},
|
||||
};
|
||||
|
||||
const config = new ConfigReader(a, new ConfigReader(b));
|
||||
|
||||
expect(config.getString('merged.x')).toBe('x');
|
||||
expect(config.getString('merged.y')).toBe('y');
|
||||
expect(config.getString('merged.z')).toBe('z1');
|
||||
expect(config.getConfig('merged').getString('x')).toBe('x');
|
||||
expect(config.getConfig('merged').getString('y')).toBe('y');
|
||||
expect(config.getConfig('merged').getString('z')).toBe('z1');
|
||||
expect(config.getString('merged.config.d')).toBe('d');
|
||||
expect(config.getString('merged.config.e')).toBe('e');
|
||||
expect(config.getConfig('merged').getString('config.d')).toBe('d');
|
||||
expect(config.getConfig('merged').getString('config.e')).toBe('e');
|
||||
expect(config.getConfig('merged').getConfig('config').getString('d')).toBe(
|
||||
'd',
|
||||
);
|
||||
expect(config.getConfig('merged').getConfig('config').getString('e')).toBe(
|
||||
'e',
|
||||
);
|
||||
|
||||
// Arrays are not merged
|
||||
expect(config.getStringArray('merged.arr')).toEqual(['a', 'b']);
|
||||
expect(config.getConfig('merged').getStringArray('arr')).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
|
||||
// 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('b'),
|
||||
).toBeUndefined();
|
||||
|
||||
// Config arrays aren't merged either
|
||||
expect(config.getConfig('merged').getConfigArray('configs').length).toBe(1);
|
||||
expect(
|
||||
config.getConfig('merged').getConfigArray('configs')[0].getString('a'),
|
||||
).toBe('a');
|
||||
expect(
|
||||
config.getConfig('merged').getConfigArray('configs')[0].getString('b'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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 { ConfigApi, Config } from '../../definitions/ConfigApi';
|
||||
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
|
||||
type JsonObject = { [key in string]: JsonValue };
|
||||
type JsonArray = JsonValue[];
|
||||
type JsonValue = JsonObject | JsonArray | number | string | boolean | null;
|
||||
|
||||
function isObject(value: JsonValue | undefined): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function typeOf(value: JsonValue | undefined): string {
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
} else if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
}
|
||||
const type = typeof value;
|
||||
if (type === 'number' && isNaN(value as number)) {
|
||||
return 'nan';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function typeErrorMessage(key: string, got: string, wanted: string) {
|
||||
return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`;
|
||||
}
|
||||
|
||||
function validateString(
|
||||
key: string,
|
||||
value: JsonValue | undefined,
|
||||
): value is string {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (value === '') {
|
||||
throw new TypeError(typeErrorMessage(key, 'empty-string', 'string'));
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'string'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class ConfigReader implements ConfigApi {
|
||||
static nullReader = new ConfigReader({});
|
||||
|
||||
constructor(
|
||||
private readonly data: JsonObject,
|
||||
private readonly fallback?: ConfigApi,
|
||||
) {}
|
||||
|
||||
getConfig(key: string): Config {
|
||||
const value = this.readValue(key);
|
||||
const fallbackConfig = this.fallback?.getConfig(key);
|
||||
if (isObject(value)) {
|
||||
return new ConfigReader(value, fallbackConfig);
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'object'));
|
||||
}
|
||||
return fallbackConfig ?? ConfigReader.nullReader;
|
||||
}
|
||||
|
||||
getConfigArray(key: string): Config[] {
|
||||
const values = this.readValue(key);
|
||||
if (Array.isArray(values)) {
|
||||
return values.map((value, index) => {
|
||||
if (isObject(value)) {
|
||||
return new ConfigReader(value);
|
||||
}
|
||||
throw new TypeError(
|
||||
typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (values !== undefined) {
|
||||
throw new TypeError(
|
||||
typeErrorMessage(key, typeOf(values), 'object-array'),
|
||||
);
|
||||
}
|
||||
return this.fallback?.getConfigArray(key) ?? [];
|
||||
}
|
||||
|
||||
getNumber(key: string): number | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (typeof value === 'number' && !isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'number'));
|
||||
}
|
||||
return this.fallback?.getNumber(key);
|
||||
}
|
||||
|
||||
getBoolean(key: string): boolean | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean'));
|
||||
}
|
||||
return this.fallback?.getBoolean(key);
|
||||
}
|
||||
|
||||
getString(key: string): string | undefined {
|
||||
const value = this.readValue(key);
|
||||
if (validateString(key, value)) {
|
||||
return value;
|
||||
}
|
||||
return this.fallback?.getString(key);
|
||||
}
|
||||
|
||||
getStringArray(key: string): string[] | undefined {
|
||||
const values = this.readValue(key);
|
||||
if (Array.isArray(values)) {
|
||||
for (const [index, value] of values.entries()) {
|
||||
const iKey = `${key}[${index}]`;
|
||||
if (!validateString(iKey, value)) {
|
||||
throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string'));
|
||||
}
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
if (values !== undefined) {
|
||||
throw new TypeError(
|
||||
typeErrorMessage(key, typeOf(values), 'string-array'),
|
||||
);
|
||||
}
|
||||
return this.fallback?.getStringArray(key);
|
||||
}
|
||||
|
||||
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 (isObject(value)) {
|
||||
value = value[part];
|
||||
} else {
|
||||
value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { LoginPage } from './LoginPage';
|
||||
export { ConfigReader } from './ConfigReader';
|
||||
@@ -22,5 +22,6 @@ export * from './auth';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './OAuthRequestApi';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import React, { ComponentType, FC } from 'react';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { BackstageApp, AppComponents } from './types';
|
||||
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { FeatureFlagsRegistryItem } from './FeatureFlags';
|
||||
import { featureFlagsApiRef } from '../apis/definitions';
|
||||
@@ -31,8 +31,11 @@ import {
|
||||
AppTheme,
|
||||
AppThemeSelector,
|
||||
appThemeApiRef,
|
||||
configApiRef,
|
||||
ConfigReader,
|
||||
} from '../apis';
|
||||
import { ApiAggregator } from '../apis/ApiAggregator';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
type FullAppOptions = {
|
||||
apis: ApiHolder;
|
||||
@@ -40,6 +43,7 @@ type FullAppOptions = {
|
||||
plugins: BackstagePlugin[];
|
||||
components: AppComponents;
|
||||
themes: AppTheme[];
|
||||
configLoader: AppConfigLoader;
|
||||
};
|
||||
|
||||
export class PrivateAppImpl implements BackstageApp {
|
||||
@@ -48,6 +52,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
private readonly plugins: BackstagePlugin[];
|
||||
private readonly components: AppComponents;
|
||||
private readonly themes: AppTheme[];
|
||||
private readonly configLoader: AppConfigLoader;
|
||||
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
@@ -55,6 +60,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
this.plugins = options.plugins;
|
||||
this.components = options.components;
|
||||
this.themes = options.themes;
|
||||
this.configLoader = options.configLoader;
|
||||
}
|
||||
|
||||
getApis(): ApiHolder {
|
||||
@@ -141,18 +147,33 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
|
||||
getProvider(): ComponentType<{}> {
|
||||
const appApis = ApiRegistry.from([
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
]);
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
const Provider: FC<{}> = ({ children }) => {
|
||||
const config = useAsync(this.configLoader);
|
||||
|
||||
const Provider: FC<{}> = ({ children }) => (
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{children}</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
let childNode = children;
|
||||
|
||||
if (config.loading) {
|
||||
const { Progress } = this.components;
|
||||
childNode = <Progress />;
|
||||
} else if (config.error) {
|
||||
const { BootErrorPage } = this.components;
|
||||
childNode = <BootErrorPage step="load-config" error={config.error} />;
|
||||
}
|
||||
|
||||
const appApis = ApiRegistry.from([
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
[configApiRef, new ConfigReader(config.value ?? {})],
|
||||
]);
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
|
||||
return (
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{childNode}</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
return Provider;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,27 @@ import { BackstagePlugin } from '../plugin';
|
||||
import { ApiHolder } from '../apis';
|
||||
import { AppTheme } from '../apis/definitions';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* TBD
|
||||
*/
|
||||
export type AppConfig = any;
|
||||
|
||||
/**
|
||||
* A function that loads in the App config that will be accessible via the ConfigApi.
|
||||
*/
|
||||
export type AppConfigLoader = () => Promise<AppConfig>;
|
||||
|
||||
export type AppOptions = {
|
||||
/**
|
||||
* A holder of all APIs available in the app.
|
||||
@@ -68,6 +85,17 @@ export type AppOptions = {
|
||||
* ```
|
||||
*/
|
||||
themes?: AppTheme[];
|
||||
|
||||
/**
|
||||
* A function that loads in App configuration that will be accessible via
|
||||
* the ConfigApi.
|
||||
*
|
||||
* Defaults to an empty config.
|
||||
*
|
||||
* TODO(Rugvip): Omitting this should instead default to loading in configuration
|
||||
* that was packaged by the backstage-cli and default docker container boot script.
|
||||
*/
|
||||
configLoader?: AppConfigLoader;
|
||||
};
|
||||
|
||||
export type BackstageApp = {
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import privateExports, {
|
||||
AppOptions,
|
||||
ApiRegistry,
|
||||
defaultSystemIcons,
|
||||
BootErrorPageProps,
|
||||
} from '@backstage/core-api';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
import { ErrorPage } from '../layout/ErrorPage';
|
||||
import Progress from '../components/Progress';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
|
||||
const { PrivateAppImpl } = privateExports;
|
||||
@@ -38,12 +41,26 @@ export function createApp(options?: AppOptions) {
|
||||
const DefaultNotFoundPage = () => (
|
||||
<ErrorPage status="404" statusMessage="PAGE NOT FOUND" />
|
||||
);
|
||||
const DefaultBootErrorPage: FC<BootErrorPageProps> = ({ step, error }) => {
|
||||
let message = '';
|
||||
if (step === 'load-config') {
|
||||
message = `The configuration failed to load, someone should have a look at this error: ${error.message}`;
|
||||
}
|
||||
// TODO: figure out a nicer way to handle routing on the error page, when it can be done.
|
||||
return (
|
||||
<Router>
|
||||
<ErrorPage status="501" statusMessage={message} />
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
const apis = options?.apis ?? ApiRegistry.from([]);
|
||||
const icons = { ...defaultSystemIcons, ...options?.icons };
|
||||
const plugins = options?.plugins ?? [];
|
||||
const components = {
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
BootErrorPage: DefaultBootErrorPage,
|
||||
Progress: Progress,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
@@ -60,8 +77,16 @@ export function createApp(options?: AppOptions) {
|
||||
theme: darkTheme,
|
||||
},
|
||||
];
|
||||
const configLoader = options?.configLoader ?? (async () => ({}));
|
||||
|
||||
const app = new PrivateAppImpl({ apis, icons, plugins, components, themes });
|
||||
const app = new PrivateAppImpl({
|
||||
apis,
|
||||
icons,
|
||||
plugins,
|
||||
components,
|
||||
themes,
|
||||
configLoader,
|
||||
});
|
||||
|
||||
app.verify();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
marginTop: -theme.spacing(3),
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
zIndex: 'unset',
|
||||
},
|
||||
icon: {
|
||||
fontSize: 20,
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import { Page } from '../Page';
|
||||
import { Header } from '../Header';
|
||||
import { Content } from '../Content';
|
||||
import { ContentHeader } from '../ContentHeader';
|
||||
import { InfoCard } from '../InfoCard/InfoCard';
|
||||
import {
|
||||
Grid,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
Link,
|
||||
} from '@material-ui/core';
|
||||
|
||||
enum AuthType {
|
||||
GitHub,
|
||||
}
|
||||
|
||||
export const LoginPage: FC<{}> = () => {
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
const [githubPersonalAuthToken, setGithubPersonalAuthToken] = useState(
|
||||
String,
|
||||
);
|
||||
const [loginDetails, setLoginDetails] = useState(Object);
|
||||
|
||||
const saveGithubInfo = (info: {}) => {
|
||||
localStorage.setItem('githubLoginDetails', JSON.stringify(info));
|
||||
setLoginDetails(info);
|
||||
};
|
||||
|
||||
const deleteGithubInfo = () => {
|
||||
localStorage.removeItem('githubLoginDetails');
|
||||
setLoginDetails(undefined);
|
||||
};
|
||||
|
||||
const handleTokenRegistration = (event: any) => {
|
||||
switch (event.target.name) {
|
||||
case 'github-username-tf':
|
||||
setGithubUsername(event.target.value);
|
||||
break;
|
||||
case 'github-auth-tf':
|
||||
setGithubPersonalAuthToken(event.target.value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubToken = (username: String, token: String) => {
|
||||
fetch('https://api.github.com/user', {
|
||||
headers: new Headers({
|
||||
Authorization: `Basic ${btoa(`${username}:${token}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 200) return response.json();
|
||||
throw Error(`${response.status} ${response.statusText}`);
|
||||
})
|
||||
.then(data => {
|
||||
const info = {
|
||||
username: username,
|
||||
token: token,
|
||||
name: data.name || data.login,
|
||||
};
|
||||
saveGithubInfo(info);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const validateUsernameAndToken = (username: String, token: String) => {
|
||||
if (username === undefined || username === null || username === '')
|
||||
return false;
|
||||
|
||||
if (token === undefined || token === null || token === '') return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const authenticate = (type: AuthType) => {
|
||||
switch (type) {
|
||||
case AuthType.GitHub:
|
||||
{
|
||||
const username = githubUsername;
|
||||
const token = githubPersonalAuthToken;
|
||||
if (validateUsernameAndToken(username, token))
|
||||
fetchGitHubToken(username, token);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginIndicator = () => {
|
||||
const ls = localStorage.getItem('githubLoginDetails');
|
||||
if (ls !== null) {
|
||||
const obj = ls || loginDetails ? JSON.parse(ls) : loginDetails;
|
||||
return (
|
||||
<Typography variant="h6" component="h2">
|
||||
{`Welcome, ${obj.name}!`}
|
||||
<br />
|
||||
<Link onClick={deleteGithubInfo}>Logout</Link>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography variant="h6" component="h2">
|
||||
Welcome, guest!
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header title="Login">
|
||||
<LoginIndicator />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Choose a method to authenticate" />
|
||||
<Grid container>
|
||||
<Grid item>
|
||||
<InfoCard>
|
||||
<Typography variant="h6">
|
||||
<GitHubIcon /> GitHub
|
||||
</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-username-tf"
|
||||
label="Username"
|
||||
onChange={handleTokenRegistration}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-auth-tf"
|
||||
label="Token"
|
||||
onChange={handleTokenRegistration}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Button
|
||||
data-testid="github-auth-button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => authenticate(AuthType.GitHub)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,6 @@ export * from './Header';
|
||||
export * from './HeaderLabel';
|
||||
export * from './HomepageTimer';
|
||||
export * from './InfoCard';
|
||||
export * from './LoginPage';
|
||||
export * from './Page';
|
||||
export * from './Sidebar';
|
||||
export * from './TabbedCard';
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
|
||||
@@ -19,14 +19,23 @@ import { render } from '@testing-library/react';
|
||||
import WelcomePage from './WelcomePage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
errorApiRef,
|
||||
configApiRef,
|
||||
ConfigReader,
|
||||
} from '@backstage/core';
|
||||
|
||||
describe('WelcomePage', () => {
|
||||
it('should render', () => {
|
||||
// TODO: use common test app with mock implementations of all core APIs
|
||||
const rendered = render(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([[errorApiRef, { post: jest.fn() }]])}
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, { post: jest.fn() }],
|
||||
[configApiRef, new ConfigReader({})],
|
||||
])}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<WelcomePage />
|
||||
|
||||
@@ -34,15 +34,18 @@ import {
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
WarningPanel,
|
||||
useApi,
|
||||
configApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
const WelcomePage: FC<{}> = () => {
|
||||
const appTitle = useApi(configApiRef).getString('app.title') ?? 'Backstage';
|
||||
const profile = { givenName: '' };
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
title={`Welcome ${profile.givenName || 'to Backstage'}`}
|
||||
title={`Welcome ${profile.givenName || `to ${appTitle}`}`}
|
||||
subtitle="Let's start building a better developer experience"
|
||||
>
|
||||
<HomepageTimer />
|
||||
|
||||
@@ -6871,9 +6871,9 @@ clone@^1.0.2:
|
||||
integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
|
||||
|
||||
clsx@^1.0.1, clsx@^1.0.2, clsx@^1.0.4, clsx@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702"
|
||||
integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA==
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
|
||||
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
|
||||
|
||||
cmd-shim@^3.0.0, cmd-shim@^3.0.3:
|
||||
version "3.0.3"
|
||||
@@ -7866,9 +7866,9 @@ cyclist@^1.0.1:
|
||||
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
|
||||
|
||||
cypress@*, cypress@^4.2.0:
|
||||
version "4.6.0"
|
||||
resolved "https://registry.npmjs.org/cypress/-/cypress-4.6.0.tgz#ac76786500580df1347a0a50be63e5c59ffbef59"
|
||||
integrity sha512-vIPXAceRP+Nxvnm/O9ruY9EQaRGmVVybtk9F1sfC9mH3067YbitrdBTynaaLuHFj90p9e0U2ZCV7OkX4x4V/Wg==
|
||||
version "4.7.0"
|
||||
resolved "https://registry.npmjs.org/cypress/-/cypress-4.7.0.tgz#3ea29bddaf9a1faeaa5b8d54b60a84ed1cafa83d"
|
||||
integrity sha512-Vav6wUFhPRlImIND/2lOQlUnAWzgCC/iXyJlJjX9nJOJul5LC1vUpf/m8Oiae870PFPyT0ZLLwPHKTXZNdXmHw==
|
||||
dependencies:
|
||||
"@cypress/listr-verbose-renderer" "0.4.1"
|
||||
"@cypress/request" "2.88.5"
|
||||
@@ -18353,9 +18353,9 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
|
||||
inherits "^2.0.1"
|
||||
|
||||
rollup-plugin-dts@^1.4.6:
|
||||
version "1.4.6"
|
||||
resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.6.tgz#26e3da11ec647cfffee9658b63fa41d67e7840b9"
|
||||
integrity sha512-1o5+eI97Ne8zXJrgdasn/xGi0xKuovCQwZRtPI2Lfl/c6qa9jQTFbn60NwOx3gWJ89K265/6kpDuahnBbplyWA==
|
||||
version "1.4.7"
|
||||
resolved "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-1.4.7.tgz#6255147ac777314c0725a1efcb42df10fe282243"
|
||||
integrity sha512-QkunbJ96yUNkW95k/Vd6SdTjCbWSG0rMVUtpHSCwfg078Z7vbDaBnfz/gkSqR5h8WFMxoccBT4aodHm6387Jvg==
|
||||
optionalDependencies:
|
||||
"@babel/code-frame" "^7.8.3"
|
||||
|
||||
@@ -19328,9 +19328,9 @@ stacktrace-js@^2.0.0:
|
||||
stacktrace-gps "^3.0.4"
|
||||
|
||||
start-server-and-test@^1.10.11:
|
||||
version "1.10.11"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.10.11.tgz#24290ee8a5ed15f4a34e9bb45a5d6ff93c93c83e"
|
||||
integrity sha512-CZilaj293uQWdD4vgOxTOuzlCWxOyBm6bzmH1r6OGLG/q5zcBmGYevLfOimkg0kSn9jLHwYSXLuoKG/DDQJhww==
|
||||
version "1.11.0"
|
||||
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.11.0.tgz#1b1a83d062b0028ee6e296bb4e0231f2d8b2f4af"
|
||||
integrity sha512-FhkJFYL/lvbd0tKWvbxWNWjtFtq3Zpa09QDjA8EUH88AsgNL4hkAAKYNmbac+fFM8/GIZoJ1Mj4mm3SMI0X1bA==
|
||||
dependencies:
|
||||
bluebird "3.7.2"
|
||||
check-more-types "2.24.0"
|
||||
|
||||
Reference in New Issue
Block a user