Merge branch 'master' of github.com:spotify/backstage into mob/register-unregister-components

This commit is contained in:
Ivan Shmidt
2020-06-04 17:09:15 +02:00
88 changed files with 2288 additions and 425 deletions
-12
View File
@@ -25,18 +25,6 @@ import { hot } from 'react-hot-loader/root';
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();
+7 -1
View File
@@ -30,6 +30,8 @@ import {
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
import {
@@ -45,8 +47,12 @@ import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
const builder = ApiRegistry.builder();
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
const errorApi = builder.add(
errorApiRef,
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
);
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
builder.add(storageApiRef, WebStorage.create({ errorApi }));
builder.add(circleCIApiRef, new CircleCIApi());
builder.add(featureFlagsApiRef, new FeatureFlags());
+2 -1
View File
@@ -32,7 +32,7 @@
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-commonjs": "^12.0.0",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/eslint-config": "^7.0.1",
@@ -79,6 +79,7 @@
"url-loader": "^4.1.0",
"webpack": "^4.41.6",
"webpack-dev-server": "^3.10.3",
"yaml": "^1.10.0",
"yml-loader": "^2.1.0",
"yn": "^4.0.0"
},
+2
View File
@@ -16,10 +16,12 @@
import { buildBundle } from '../../lib/bundler';
import { Command } from 'commander';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
await buildBundle({
entry: 'src/index',
statsJsonEnabled: cmd.stats,
appConfig: await loadConfig(),
});
};
+2
View File
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'src/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+1
View File
@@ -24,6 +24,7 @@ export default async function clean() {
const packagePath = getPackagePath(cacheOptions.cacheDir);
await fs.remove(cacheOptions.output);
await fs.remove(packagePath);
await fs.remove(paths.resolveTarget('coverage'));
}
function getPackagePath(cacheDir: string) {
@@ -16,11 +16,13 @@
import { Command } from 'commander';
import { serveBundle } from '../../lib/bundler';
import { loadConfig } from '../../lib/app-config';
export default async (cmd: Command) => {
const waitForExit = await serveBundle({
entry: 'dev/index',
checksEnabled: cmd.check,
appConfig: await loadConfig(),
});
await waitForExit();
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type { AppConfig } from './types';
export { loadConfig } from './loaders';
@@ -0,0 +1,41 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AppConfig } from './types';
import fs from 'fs-extra';
import yaml from 'yaml';
import { paths } from '../paths';
type LoadConfigOptions = {
// Config path, defaults to app-config.yaml in project root
configPath?: string;
};
export async function loadConfig(
options: LoadConfigOptions = {},
): Promise<AppConfig[]> {
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
// specific env, and maybe local config for plugins.
const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options;
try {
const configYaml = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configYaml);
return [config];
} catch (error) {
throw new Error(`Failed to read static configuration file, ${error}`);
}
}
+17
View File
@@ -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 type AppConfig = any;
+6
View File
@@ -51,6 +51,12 @@ export function createConfig(
);
}
plugins.push(
new webpack.EnvironmentPlugin({
APP_CONFIG: options.appConfig,
}),
);
return {
mode: isDev ? 'development' : 'production',
profile: false,
+4
View File
@@ -15,16 +15,20 @@
*/
import { BundlingPathsOptions } from './paths';
import { AppConfig } from '../app-config';
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
appConfig: AppConfig[];
};
export type ServeOptions = BundlingPathsOptions & {
checksEnabled: boolean;
appConfig: AppConfig[];
};
export type BuildOptions = BundlingPathsOptions & {
statsJsonEnabled: boolean;
appConfig: AppConfig[];
};
@@ -0,0 +1,5 @@
app:
title: Scaffolded Backstage App
organization:
name: Acme Corporation
@@ -1,25 +1,30 @@
/*
* 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.
*/
* 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 { createPlugin } from '@backstage/core';
import { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
export const plugin = createPlugin({
id: '{{ id }}',
register({ router }) {
router.registerRoute('/{{ id }}', ExampleComponent);
},
export const rootRouteRef = createRouteRef({
path: '/{{ id }}',
title: '{{ id }}',
});
export const plugin = createPlugin({
id: '{{ id }}',
register({ router }) {
router.addRoute(rootRouteRef, ExampleComponent);
},
});
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
import { Observable } from '../../types';
import { ErrorApi } from './ErrorApi';
export type StorageValueChange<T = any> = {
key: string;
newValue?: T;
};
export type CreateStorageApiOptions = {
errorApi: ErrorApi;
namespace?: string;
};
export interface StorageApi {
/**
* Create a bucket to store data in.
* @param {String} name Namespace for the storage to be stored under,
* will inherit previous namespaces too
*/
forBucket(name: string): StorageApi;
/**
* Get the current value for persistent data, use observe$ to be notified of updates.
*
* @param {String} key Unique key associated with the data.
* @return {Object} data The data that should is stored.
*/
get<T>(key: string): T | undefined;
/**
* Remove persistent data.
*
* @param {String} key Unique key associated with the data.
*/
remove(key: string): Promise<void>;
/**
* Save persistant data, and emit messages to anyone that is using observe$ for this key
*
* @param {String} key Unique key associated with the data.
*/
set(key: string, data: any): Promise<void>;
/**
* Observe changes on a particular key in the bucket
* @param {String} key Unique key associated with the data
*/
observe$<T>(key: string): Observable<StorageValueChange<T>>;
}
export const storageApiRef = createApiRef<StorageApi>({
id: 'core.storage',
description: 'Provides the ability to store data which is unique to the user',
});
@@ -28,3 +28,4 @@ export * from './ConfigApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
@@ -15,6 +15,7 @@
*/
import { ConfigApi, Config } from '../../definitions/ConfigApi';
import { AppConfig } from '../../../app';
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
@@ -62,6 +63,18 @@ function validateString(
export class ConfigReader implements ConfigApi {
static nullReader = new ConfigReader({});
static fromConfigs(configs: AppConfig[]): ConfigReader {
if (configs.length === 0) {
return new ConfigReader({});
}
// Merge together all configs info a single config with recursive fallback
// readers, giving the first config object in the array the highest priority.
return configs.reduceRight((previousReader, nextConfig) => {
return new ConfigReader(nextConfig, previousReader);
}, undefined);
}
constructor(
private readonly data: JsonObject,
private readonly fallback?: ConfigApi,
@@ -0,0 +1,164 @@
/*
* 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 { WebStorage } from './WebStorage';
import { CreateStorageApiOptions, StorageApi } from '../../definitions';
describe('WebStorage Storage API', () => {
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
const createWebStorage = (
args?: Partial<CreateStorageApiOptions>,
): StorageApi => {
return WebStorage.create({
errorApi: mockErrorApi,
...args,
});
};
it('should return undefined for values which are unset', async () => {
const storage = createWebStorage();
expect(storage.get('myfakekey')).toBeUndefined();
});
it('should allow the setting and getting of the simple data structures', async () => {
const storage = createWebStorage();
await storage.set('myfakekey', 'helloimastring');
await storage.set('mysecondfakekey', 1234);
await storage.set('mythirdfakekey', true);
expect(storage.get('myfakekey')).toBe('helloimastring');
expect(storage.get('mysecondfakekey')).toBe(1234);
expect(storage.get('mythirdfakekey')).toBe(true);
});
it('should allow setting of complex datastructures', async () => {
const storage = createWebStorage();
const mockData = {
something: 'here',
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
};
await storage.set('myfakekey', mockData);
expect(storage.get('myfakekey')).toEqual(mockData);
});
it('should subscribe to key changes when setting a new value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
await new Promise(resolve => {
storage.observe$<String>('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.set('correctKey', mockData);
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: mockData,
});
});
it('should subscribe to key changes when deleting a value', async () => {
const storage = createWebStorage();
const wrongKeyNextHandler = jest.fn();
const selectedKeyNextHandler = jest.fn();
const mockData = { hello: 'im a great new value' };
storage.set('correctKey', mockData);
await new Promise(resolve => {
storage.observe$('correctKey').subscribe({
next: (...args) => {
selectedKeyNextHandler(...args);
resolve();
},
});
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
storage.remove('correctKey');
});
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
key: 'correctKey',
newValue: undefined,
});
});
it('should be able to create different buckets for different uses', async () => {
const rootStorage = createWebStorage();
const firstStorage = rootStorage.forBucket('userSettings');
const secondStorage = rootStorage.forBucket('profileSettings');
const keyName = 'blobby';
await firstStorage.set(keyName, 'boop');
await secondStorage.set(keyName, 'deerp');
expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName));
expect(firstStorage.get(keyName)).toBe('boop');
expect(secondStorage.get(keyName)).toBe('deerp');
});
it('should not clash with other namesapces when creating buckets', async () => {
const rootStorage = createWebStorage();
// when getting key test2 it will translate to /profile/something/deep/test2
const firstStorage = rootStorage
.forBucket('profile')
.forBucket('something')
.forBucket('deep');
// when getting key deep/test2 it will translate to /profile/something/deep/test2
const secondStorage = rootStorage.forBucket('profile/something');
await firstStorage.set('test2', { error: true });
expect(secondStorage.get('deep/test2')).toBe(undefined);
});
it('should call the error api when the json can not be parsed in local storage', async () => {
const rootStorage = createWebStorage({
namespace: '/Test/Mock/Thing',
});
localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}');
const value = rootStorage.get('key');
expect(value).toBe(undefined);
expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error));
expect(mockErrorApi.post).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Error when parsing JSON config from storage for: key',
}),
);
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
StorageApi,
StorageValueChange,
ErrorApi,
CreateStorageApiOptions,
} from '../../definitions';
import { Observable } from '../../../types';
import ObservableImpl from 'zen-observable';
export class WebStorage implements StorageApi {
constructor(
private readonly namespace: string,
private readonly errorApi: ErrorApi,
) {}
static create(options: CreateStorageApiOptions): WebStorage {
return new WebStorage(options.namespace ?? '', options.errorApi);
}
get<T>(key: string): T | undefined {
try {
const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!);
return storage ?? undefined;
} catch (e) {
this.errorApi.post(
new Error(`Error when parsing JSON config from storage for: ${key}`),
);
}
return undefined;
}
forBucket(name: string): WebStorage {
return new WebStorage(`${this.namespace}/${name}`, this.errorApi);
}
async set<T>(key: string, data: T): Promise<void> {
localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2));
this.notifyChanges({ key, newValue: data });
}
async remove(key: string): Promise<void> {
localStorage.removeItem(this.getKeyName(key));
this.notifyChanges({ key, newValue: undefined });
}
observe$<T>(key: string): Observable<StorageValueChange<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T>(message: StorageValueChange<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueChange>
>();
private readonly observable = new ObservableImpl<StorageValueChange>(
subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
},
);
}
@@ -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 { WebStorage } from './WebStorage';
@@ -25,3 +25,4 @@ export * from './AppThemeApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './OAuthRequestApi';
export * from './StorageApi';
+16 -3
View File
@@ -110,7 +110,7 @@ export class PrivateAppImpl implements BackstageApp {
);
break;
}
case 'redirect-route': {
case 'legacy-redirect-route': {
const { path, target, options = {} } = output;
const { exact = true } = options;
routes.push(
@@ -118,6 +118,19 @@ export class PrivateAppImpl implements BackstageApp {
);
break;
}
case 'redirect-route': {
const { from, to, options = {} } = output;
const { exact = true } = options;
routes.push(
<Redirect
key={from.path}
path={from.path}
to={to.path}
exact={exact}
/>,
);
break;
}
case 'feature-flag': {
registeredFeatureFlags.push({
pluginId: plugin.getId(),
@@ -150,7 +163,7 @@ export class PrivateAppImpl implements BackstageApp {
const Provider: FC<{}> = ({ children }) => {
// Keeping this synchronous when a config loader isn't set simplifies tests a lot
const hasConfig = Boolean(this.configLoader);
const config = useAsync(this.configLoader || (() => Promise.resolve({})));
const config = useAsync(this.configLoader || (() => Promise.resolve([])));
let childNode = children;
@@ -164,7 +177,7 @@ export class PrivateAppImpl implements BackstageApp {
const appApis = ApiRegistry.from([
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
[configApiRef, new ConfigReader(config.value ?? {})],
[configApiRef, ConfigReader.fromConfigs(config.value ?? [])],
]);
const apis = new ApiAggregator(this.apis, appApis);
+4 -1
View File
@@ -38,8 +38,11 @@ export type AppConfig = any;
/**
* A function that loads in the App config that will be accessible via the ConfigApi.
*
* If multiple config objects are returned in the array, values in the earlier configs
* will override later ones.
*/
export type AppConfigLoader = () => Promise<AppConfig>;
export type AppConfigLoader = () => Promise<AppConfig[]>;
export type AppOptions = {
/**
@@ -76,7 +76,7 @@ describe('DefaultAuthConnector', () => {
const helper = new DefaultAuthConnector(defaultOptions);
await expect(helper.refreshSession()).rejects.toThrow(
'Auth refresh request failed with status NOPE',
'Auth refresh request failed, NOPE',
);
});
@@ -115,7 +115,7 @@ export class DefaultAuthConnector<AuthSession>
if (!res.ok) {
const error: any = new Error(
`Auth refresh request failed with status ${res.statusText}`,
`Auth refresh request failed, ${res.statusText}`,
);
error.status = res.status;
throw error;
@@ -140,10 +140,14 @@ export class DefaultAuthConnector<AuthSession>
'x-requested-with': 'XMLHttpRequest',
},
credentials: 'include',
}).catch(error => {
throw new Error(`Logout request failed, ${error}`);
});
if (!res.ok) {
throw new Error(`Logout request failed with status ${res.status}`);
const error: any = new Error(`Logout request failed, ${res.statusText}`);
error.status = res.status;
throw error;
}
}
+3 -9
View File
@@ -42,17 +42,14 @@ export type RouterHooks = {
options?: RouteOptions,
): void;
/**
* @deprecated See the `addRoute` method
*/
registerRoute(
path: RoutePath,
Component: ComponentType<any>,
options?: RouteOptions,
): void;
registerRedirect(
path: RoutePath,
target: RoutePath,
options?: RouteOptions,
): void;
};
export type FeatureFlagsHooks = {
@@ -91,9 +88,6 @@ export class PluginImpl {
registerRoute(path, component, options) {
outputs.push({ type: 'legacy-route', path, component, options });
},
registerRedirect(path, target, options) {
outputs.push({ type: 'redirect-route', path, target, options });
},
},
featureFlags: {
register(name) {
+8
View File
@@ -41,6 +41,13 @@ export type RouteOutput = {
export type RedirectRouteOutput = {
type: 'redirect-route';
from: RouteRef;
to: RouteRef;
options?: RouteOptions;
};
export type LegacyRedirectRouteOutput = {
type: 'legacy-redirect-route';
path: RoutePath;
target: RoutePath;
options?: RouteOptions;
@@ -56,6 +63,7 @@ export type FeatureFlagOutput = {
export type PluginOutput =
| LegacyRouteOutput
| RouteOutput
| LegacyRedirectRouteOutput
| RedirectRouteOutput
| FeatureFlagOutput;
+2 -2
View File
@@ -18,13 +18,13 @@ import { IconComponent } from '../icons';
export type RouteRef = {
path: string;
icon: IconComponent;
icon?: IconComponent;
title: string;
};
export type RouteRefConfig = {
path: string;
icon: IconComponent;
icon?: IconComponent;
title: string;
};
+1
View File
@@ -34,6 +34,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"@types/react-router-dom": "^5.1.5",
"@types/react-sparklines": "^1.7.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
@@ -0,0 +1,74 @@
/*
* 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 { defaultConfigLoader } from './createApp';
describe('defaultConfigLoader', () => {
afterEach(() => {
delete process.env.APP_CONFIG;
});
it('loads static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }, { my: 'override-config' }] as any,
});
const configs = await defaultConfigLoader();
expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]);
});
it('loads runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'override-config' }, { my: 'config' }] as any,
});
const configs = await (defaultConfigLoader as any)(
'{"my":"runtime-config"}',
);
expect(configs).toEqual([
{ my: 'runtime-config' },
{ my: 'override-config' },
{ my: 'config' },
]);
});
it('fails to load invalid missing config', async () => {
await expect(defaultConfigLoader()).rejects.toThrow(
'No static configuration provided',
);
});
it('fails to load invalid static config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: { my: 'invalid-config' } as any,
});
await expect(defaultConfigLoader()).rejects.toThrow(
'Static configuration has invalid format',
);
});
it('fails to load bad runtime config', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [{ my: 'config' }] as any,
});
await expect((defaultConfigLoader as any)('}')).rejects.toThrow(
'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0',
);
});
});
+40 -1
View File
@@ -20,6 +20,8 @@ import privateExports, {
ApiRegistry,
defaultSystemIcons,
BootErrorPageProps,
AppConfigLoader,
AppConfig,
} from '@backstage/core-api';
import { BrowserRouter as Router } from 'react-router-dom';
@@ -29,6 +31,43 @@ import { lightTheme, darkTheme } from '@backstage/theme';
const { PrivateAppImpl } = privateExports;
/**
* The default config loader, which expects that config is available at compile-time
* in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as
* returned by the config loader.
*
* It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string,
* which can be rewritten at runtime to contain an additional JSON config object.
* If runtime config is present, it will be placed first in the config array, overriding
* other config values.
*/
export const defaultConfigLoader: AppConfigLoader = async (
// This string may be replaced at runtime to provide additional config.
// It should be replaced by a JSON-serialized config object.
// It's a param so we can test it, but at runtime this will always fall back to default.
runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__',
) => {
const appConfig = process.env.APP_CONFIG;
if (!appConfig) {
throw new Error('No static configuration provided');
}
if (!Array.isArray(appConfig)) {
throw new Error('Static configuration has invalid format');
}
const configs = (appConfig.slice() as unknown) as AppConfig[];
// Avoiding this string also being replaced at runtime
if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) {
try {
configs.unshift(JSON.parse(runtimeConfigJson));
} catch (error) {
throw new Error(`Failed to load runtime configuration, ${error}`);
}
}
return configs;
};
// createApp is defined in core, and not core-api, since we need access
// to the components inside core to provide defaults.
// The actual implementation of the app class still lives in core-api,
@@ -77,7 +116,7 @@ export function createApp(options?: AppOptions) {
theme: darkTheme,
},
];
const configLoader = options?.configLoader ?? (async () => ({}));
const configLoader = options?.configLoader ?? defaultConfigLoader;
const app = new PrivateAppImpl({
apis,
@@ -0,0 +1,93 @@
/*
* 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, { FunctionComponentFactory } from 'react';
import { Button } from './Button';
import {
MemoryRouter,
Route,
useLocation,
Link as RouterLink,
} from 'react-router-dom';
import { createRouteRef } from '@backstage/core-api';
const Location = () => {
const location = useLocation();
return <pre>Current location: {location.pathname}</pre>;
};
export default {
title: 'Button',
component: Button,
decorators: [
(storyFn: FunctionComponentFactory<{}>) => (
<MemoryRouter>
<div>
<div>
<Location />
</div>
{storyFn()}
</div>
</MemoryRouter>
),
],
};
export const Default = () => {
const routeRef = createRouteRef({
path: '/hello',
title: 'Hi there!',
});
return (
<>
<Button to={routeRef.path}>This button</Button>&nbsp;will utilise the
react-router MemoryRouter's navigation
<Route path={routeRef.path}>
<h1>{routeRef.title}</h1>
</Route>
</>
);
};
export const PassProps = () => {
const routeRef = createRouteRef({
path: '/hello',
title: 'Hi there!',
});
return (
<>
<Button
to={routeRef.path}
/** react-router-dom related prop */
component={RouterLink}
/** material-ui related prop */
color="secondary"
variant="outlined"
>
This link
</Button>
&nbsp;has props for both material-ui's component as well as for
react-router-dom's
<Route path={routeRef.path}>
<h1>{routeRef.title}</h1>
</Route>
</>
);
};
PassProps.story = {
name: `Accepts material-ui Button's and react-router-dom Link's props`,
};
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Button } from './Button';
import { MemoryRouter, Route } from 'react-router';
import { act } from 'react-dom/test-utils';
describe('<Button />', () => {
it('navigates using react-router', async () => {
const testString = 'This is test string';
const buttonLabel = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Button to="/test">{buttonLabel}</Button>
<Route path="/test">{testString}</Route>{' '}
</MemoryRouter>,
),
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(buttonLabel)));
expect(getByText(testString)).toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
/*
* 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, { ComponentProps } from 'react';
import { Button as MaterialButton } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
type Props = ComponentProps<typeof MaterialButton> &
ComponentProps<typeof RouterLink>;
/**
* Thin wrapper on top of material-ui's Button component
* Makes the Button to utilise react-router
*/
export const Button = React.forwardRef<any, Props>((props, ref) => (
<MaterialButton ref={ref} component={RouterLink} {...props} />
));
@@ -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 { Button } from './Button';
@@ -0,0 +1,92 @@
/*
* 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, { FunctionComponentFactory } from 'react';
import { Link } from './Link';
import {
MemoryRouter,
Route,
useLocation,
NavLink as RouterNavLink,
} from 'react-router-dom';
import { createRouteRef } from '@backstage/core-api';
const Location = () => {
const location = useLocation();
return <pre>Current location: {location.pathname}</pre>;
};
export default {
title: 'Link',
component: Link,
decorators: [
(storyFn: FunctionComponentFactory<{}>) => (
<MemoryRouter>
<div>
<div>
<Location />
</div>
{storyFn()}
</div>
</MemoryRouter>
),
],
};
export const Default = () => {
const routeRef = createRouteRef({
path: '/hello',
title: 'Hi there!',
});
return (
<>
<Link to={routeRef.path}>This link</Link>&nbsp;will utilise the
react-router MemoryRouter's navigation
<Route path={routeRef.path}>
<h1>{routeRef.title}</h1>
</Route>
</>
);
};
export const PassProps = () => {
const routeRef = createRouteRef({
path: '/hello',
title: 'Hi there!',
});
return (
<>
<Link
to={routeRef.path}
/** react-router-dom related prop */
component={RouterNavLink}
/** material-ui related prop */
color="secondary"
>
This link
</Link>
&nbsp;has props for both material-ui's component as well as for
react-router-dom's
<Route path={routeRef.path}>
<h1>{routeRef.title}</h1>
</Route>
</>
);
};
PassProps.story = {
name: `Accepts material-ui Link's and react-router-dom Link's props`,
};
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Link } from './Link';
import { MemoryRouter, Route } from 'react-router';
import { act } from 'react-dom/test-utils';
describe('<Link />', () => {
it('navigates using react-router', async () => {
const testString = 'This is test string';
const linkText = 'Navigate!';
const { getByText } = render(
wrapInTestApp(
<MemoryRouter>
<Link to="/test">{linkText}</Link>
<Route path="/test">{testString}</Route>
</MemoryRouter>,
),
);
expect(() => getByText(testString)).toThrow();
await act(async () => fireEvent.click(getByText(linkText)));
expect(getByText(testString)).toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
/*
* 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, { ComponentProps } from 'react';
import { Link as MaterialLink } from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
type Props = ComponentProps<typeof MaterialLink> &
ComponentProps<typeof RouterLink>;
/**
* Thin wrapper on top of material-ui's Link component
* Makes the Link to utilise react-router
*/
export const Link = React.forwardRef<any, Props>((props, ref) => (
<MaterialLink ref={ref} component={RouterLink} {...props} />
));
@@ -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 { Link } from './Link';
+2
View File
@@ -38,4 +38,6 @@ export { default as StructuredMetadataTable } from './components/StructuredMetad
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';
+2 -1
View File
@@ -33,6 +33,7 @@ import {
OAuthRequestDialog,
} from '@backstage/core';
import * as defaultApiFactories from './apiFactories';
import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied';
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
type BackstagePlugin = ReturnType<typeof createPlugin>;
@@ -148,7 +149,7 @@ class DevAppBuilder {
key={target.path}
to={target.path}
text={target.title}
icon={target.icon}
icon={target.icon ?? SentimentDissatisfiedIcon}
/>,
);
break;