Merge branch 'master' of github.com:spotify/backstage into mob/register-unregister-components
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -51,6 +51,12 @@ export function createConfig(
|
||||
);
|
||||
}
|
||||
|
||||
plugins.push(
|
||||
new webpack.EnvironmentPlugin({
|
||||
APP_CONFIG: options.appConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: isDev ? 'development' : 'production',
|
||||
profile: false,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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> 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>
|
||||
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> 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>
|
||||
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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user