Merge remote-tracking branch 'upstream/master'
This commit is contained in:
+10
-48
@@ -14,8 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CssBaseline, ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { createApp } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
@@ -23,7 +21,6 @@ import Root from './components/Root';
|
||||
import AlertDisplay from './components/AlertDisplay';
|
||||
import * as plugins from './plugins';
|
||||
import apis, { alertApiForwarder } from './apis';
|
||||
import { ThemeContextType, ThemeContext, useThemeType } from './ThemeContext';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
@@ -33,50 +30,15 @@ const app = createApp({
|
||||
const AppProvider = app.getProvider();
|
||||
const AppComponent = app.getRootComponent();
|
||||
|
||||
const App: FC<{}> = () => {
|
||||
const [theme, toggleTheme] = useThemeType(
|
||||
localStorage.getItem('theme') || 'auto',
|
||||
);
|
||||
|
||||
let backstageTheme = lightTheme;
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
backstageTheme = lightTheme;
|
||||
break;
|
||||
case 'dark':
|
||||
backstageTheme = darkTheme;
|
||||
break;
|
||||
default:
|
||||
if (!window.matchMedia) {
|
||||
backstageTheme = lightTheme;
|
||||
break;
|
||||
}
|
||||
backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? darkTheme
|
||||
: lightTheme;
|
||||
break;
|
||||
}
|
||||
|
||||
const themeContext: ThemeContextType = {
|
||||
theme,
|
||||
toggleTheme,
|
||||
};
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeContext.Provider value={themeContext}>
|
||||
<ThemeProvider theme={backstageTheme}>
|
||||
<CssBaseline>
|
||||
<AlertDisplay forwarder={alertApiForwarder} />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
const App: FC<{}> = () => (
|
||||
<AppProvider>
|
||||
<AlertDisplay forwarder={alertApiForwarder} />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,69 +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, { useState, useEffect } from 'react';
|
||||
export type ThemeContextType = {
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
export const ThemeContext = React.createContext<ThemeContextType>({
|
||||
theme: 'light',
|
||||
toggleTheme: () => {},
|
||||
});
|
||||
|
||||
export function useThemeType(themeId: string): [string, () => void] {
|
||||
const [theme, setTheme] = useState(themeId);
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) {
|
||||
return () => {};
|
||||
}
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const darkListener = (event: MediaQueryListEvent) => {
|
||||
if (localStorage.getItem('theme') === 'auto') {
|
||||
if (event.matches) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
setTheme('auto');
|
||||
}
|
||||
};
|
||||
mql.addListener(darkListener);
|
||||
return () => {
|
||||
mql.removeListener(darkListener);
|
||||
};
|
||||
});
|
||||
function toggleTheme() {
|
||||
if (theme === 'light') {
|
||||
setTheme('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else if (theme === 'dark') {
|
||||
if (!window.matchMedia) {
|
||||
setTheme('light');
|
||||
localStorage.setItem('theme', 'light');
|
||||
setTheme('light');
|
||||
return;
|
||||
}
|
||||
setTheme('auto');
|
||||
localStorage.setItem('theme', 'auto');
|
||||
setTheme('auto');
|
||||
} else {
|
||||
setTheme('light');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}
|
||||
return [theme, toggleTheme];
|
||||
}
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
SidebarSpacer,
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
SidebarThemeToggle,
|
||||
} from '@backstage/core';
|
||||
import ToggleThemeSidebarItem from './ToggleThemeSidebarItem';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
@@ -89,7 +89,7 @@ const Root: FC<{}> = ({ children }) => (
|
||||
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
|
||||
<SidebarDivider />
|
||||
<SidebarSpace />
|
||||
<ToggleThemeSidebarItem />
|
||||
<SidebarThemeToggle />
|
||||
</Sidebar>
|
||||
{children}
|
||||
</SidebarPage>
|
||||
|
||||
@@ -1,56 +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 } from 'react';
|
||||
import { SidebarItem } from '@backstage/core';
|
||||
import { ThemeContext } from '../../ThemeContext';
|
||||
import WbSunnyIcon from '@material-ui/icons/WbSunny';
|
||||
import Brightness2Icon from '@material-ui/icons/Brightness2';
|
||||
import ToggleOnIcon from '@material-ui/icons/ToggleOn';
|
||||
const ToggleThemeSidebarItem: FC<{}> = () => {
|
||||
return (
|
||||
<ThemeContext.Consumer>
|
||||
{themeContext => {
|
||||
let text = 'Auto';
|
||||
let icon = ToggleOnIcon;
|
||||
switch (themeContext.theme) {
|
||||
case 'dark':
|
||||
text = 'Dark mode';
|
||||
icon = Brightness2Icon;
|
||||
break;
|
||||
case 'light':
|
||||
text = 'Light mode';
|
||||
icon = WbSunnyIcon;
|
||||
break;
|
||||
default:
|
||||
text = 'Auto';
|
||||
icon = ToggleOnIcon;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
text={text}
|
||||
onClick={themeContext.toggleTheme}
|
||||
icon={icon}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ThemeContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleThemeSidebarItem;
|
||||
@@ -19,6 +19,7 @@
|
||||
"@backstage/backend-common": "^0.1.1-alpha.5",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.5",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.5",
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.5",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
|
||||
@@ -35,6 +35,7 @@ import helmet from 'helmet';
|
||||
import knex from 'knex';
|
||||
import catalog from './plugins/catalog';
|
||||
import scaffolder from './plugins/scaffolder';
|
||||
import auth from './plugins/auth';
|
||||
import { PluginEnvironment } from './types';
|
||||
|
||||
const DEFAULT_PORT = 7000;
|
||||
@@ -63,6 +64,7 @@ async function main() {
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/catalog', await catalog(createEnv('catalog')));
|
||||
app.use('/scaffolder', await scaffolder(createEnv('scaffolder')));
|
||||
app.use('/auth', await auth(createEnv('auth')));
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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 { createRouter } from '@backstage/plugin-auth-backend';
|
||||
import { PluginEnvironment } from '../types';
|
||||
|
||||
export default async function ({ logger }: PluginEnvironment) {
|
||||
return await createRouter({ logger });
|
||||
}
|
||||
@@ -57,6 +57,19 @@ module.exports = {
|
||||
'warn',
|
||||
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
|
||||
],
|
||||
|
||||
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
|
||||
'no-restricted-imports': [
|
||||
2,
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@material-ui/icons',
|
||||
message: "Please import '@material-ui/icons/<Icon>' instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { createApp } from '@backstage/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import * as plugins from './plugins';
|
||||
@@ -34,13 +33,9 @@ const App: FC<{}> = () => {
|
||||
useStyles();
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 { ApiAggregator } from './ApiAggregator';
|
||||
import { ApiRef } from './ApiRef';
|
||||
import { ApiRegistry } from './ApiRegistry';
|
||||
|
||||
describe('ApiAggregator', () => {
|
||||
const apiARef = new ApiRef<number>({ id: 'a', description: '' });
|
||||
const apiBRef = new ApiRef<number>({ id: 'b', description: '' });
|
||||
|
||||
it('should forward implementations', () => {
|
||||
const agg = new ApiAggregator(
|
||||
ApiRegistry.from([
|
||||
[apiARef, 5],
|
||||
[apiBRef, 10],
|
||||
]),
|
||||
);
|
||||
expect(agg.get(apiARef)).toBe(5);
|
||||
expect(agg.get(apiBRef)).toBe(10);
|
||||
});
|
||||
|
||||
it('should return the first implementation', () => {
|
||||
const agg = new ApiAggregator(
|
||||
ApiRegistry.from([
|
||||
[apiARef, 1],
|
||||
[apiARef, 2],
|
||||
]),
|
||||
);
|
||||
expect(agg.get(apiARef)).toBe(2);
|
||||
expect(agg.get(apiBRef)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -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 { ApiRef } from './ApiRef';
|
||||
import { ApiHolder } from './types';
|
||||
|
||||
/**
|
||||
* An ApiHolder that queries multiple other holders from for
|
||||
* an Api implementation, returning the first one encountered..
|
||||
*/
|
||||
export class ApiAggregator implements ApiHolder {
|
||||
private readonly holders: ApiHolder[];
|
||||
|
||||
constructor(...holders: ApiHolder[]) {
|
||||
this.holders = holders;
|
||||
}
|
||||
|
||||
get<T>(apiRef: ApiRef<T>): T | undefined {
|
||||
for (const holder of this.holders) {
|
||||
const api = holder.get(apiRef);
|
||||
if (api) {
|
||||
return api;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { ApiRef } from '../ApiRef';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Observable } from '../../types';
|
||||
|
||||
/**
|
||||
* Describes a theme provided by the app.
|
||||
*/
|
||||
export type AppTheme = {
|
||||
/**
|
||||
* ID used to remember theme selections.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Title of the theme
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Theme variant
|
||||
*/
|
||||
variant: 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* The specialized MaterialUI theme instance.
|
||||
*/
|
||||
theme: BackstageTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* The AppThemeApi gives access to the current app theme, and allows switching
|
||||
* to other options that have been registered as a part of the App.
|
||||
*/
|
||||
export type AppThemeApi = {
|
||||
/**
|
||||
* Get a list of available themes.
|
||||
*/
|
||||
getInstalledThemes(): AppTheme[];
|
||||
|
||||
/**
|
||||
* Observe the currently selected theme. A value of undefined means no specific theme has been selected.
|
||||
*/
|
||||
activeThemeId$(): Observable<string | undefined>;
|
||||
|
||||
/**
|
||||
* Get the current theme ID. Returns undefined if no specific theme is selected.
|
||||
*/
|
||||
getActiveThemeId(): string | undefined;
|
||||
|
||||
/**
|
||||
* Set a specific theme to use in the app, overriding the default theme selection.
|
||||
*
|
||||
* Clear the selection by passing in undefined.
|
||||
*/
|
||||
setActiveThemeId(themeId?: string): void;
|
||||
};
|
||||
|
||||
export const appThemeApiRef = new ApiRef<AppThemeApi>({
|
||||
id: 'core.apptheme',
|
||||
description: 'API Used to configure the app theme, and enumerate options',
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
// If you think some API definition is missing, please open an Issue or send a PR!
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './FeatureFlagsApi';
|
||||
export * from './OAuthRequestApi';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { AppTheme } from '../../definitions';
|
||||
import { AppThemeSelector } from './AppThemeSelector';
|
||||
|
||||
describe('AppThemeSelector', () => {
|
||||
it('should should select new themes', async () => {
|
||||
const selector = new AppThemeSelector([]);
|
||||
|
||||
expect(selector.getInstalledThemes()).toEqual([]);
|
||||
|
||||
const subFn = jest.fn();
|
||||
selector.activeThemeId$().subscribe(subFn);
|
||||
expect(selector.getActiveThemeId()).toBe(undefined);
|
||||
await 'wait a tick';
|
||||
expect(subFn).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
selector.setActiveThemeId('x');
|
||||
expect(subFn).toHaveBeenLastCalledWith('x');
|
||||
expect(selector.getActiveThemeId()).toBe('x');
|
||||
|
||||
selector.setActiveThemeId(undefined);
|
||||
expect(subFn).toHaveBeenLastCalledWith(undefined);
|
||||
expect(selector.getActiveThemeId()).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return a new array of themes', () => {
|
||||
const themes = new Array<AppTheme>();
|
||||
const selector = new AppThemeSelector(themes);
|
||||
|
||||
expect(selector.getInstalledThemes()).toEqual(themes);
|
||||
expect(selector.getInstalledThemes()).not.toBe(themes);
|
||||
});
|
||||
|
||||
it('should store theme in local storage', async () => {
|
||||
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
|
||||
undefined,
|
||||
);
|
||||
localStorage.setItem('theme', 'x');
|
||||
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
|
||||
localStorage.removeItem('theme');
|
||||
expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const addListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const selector = AppThemeSelector.createWithStorage([]);
|
||||
|
||||
expect(addListenerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addListenerSpy).toHaveBeenCalledWith(
|
||||
'storage',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
selector.setActiveThemeId('y');
|
||||
await 'wait a tick';
|
||||
expect(localStorage.getItem('theme')).toBe('y');
|
||||
|
||||
selector.setActiveThemeId(undefined);
|
||||
await 'wait a tick';
|
||||
expect(localStorage.getItem('theme')).toBe(null);
|
||||
|
||||
localStorage.setItem('theme', 'z');
|
||||
expect(selector.getActiveThemeId()).toBe(undefined);
|
||||
|
||||
const listener = addListenerSpy.mock.calls[0][1] as EventListener;
|
||||
listener({ key: 'theme' } as StorageEvent);
|
||||
|
||||
expect(selector.getActiveThemeId()).toBe('z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 Observable from 'zen-observable';
|
||||
import { AppThemeApi, AppTheme } from '../../definitions';
|
||||
|
||||
const STORAGE_KEY = 'theme';
|
||||
|
||||
export class AppThemeSelector implements AppThemeApi {
|
||||
static createWithStorage(themes: AppTheme[]) {
|
||||
const selector = new AppThemeSelector(themes);
|
||||
|
||||
if (!window.localStorage) {
|
||||
return selector;
|
||||
}
|
||||
|
||||
const initialThemeId =
|
||||
window.localStorage.getItem(STORAGE_KEY) ?? undefined;
|
||||
|
||||
selector.setActiveThemeId(initialThemeId);
|
||||
|
||||
selector.activeThemeId$().subscribe((themeId) => {
|
||||
if (themeId) {
|
||||
window.localStorage.setItem(STORAGE_KEY, themeId);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
|
||||
selector.setActiveThemeId(themeId);
|
||||
}
|
||||
});
|
||||
|
||||
return selector;
|
||||
}
|
||||
|
||||
private readonly themes: AppTheme[];
|
||||
|
||||
private activeThemeId: string | undefined;
|
||||
|
||||
private readonly observable: Observable<string | undefined>;
|
||||
private readonly subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<string | undefined>
|
||||
>();
|
||||
|
||||
constructor(themes: AppTheme[]) {
|
||||
this.themes = themes;
|
||||
|
||||
this.observable = new Observable((subscriber) => {
|
||||
subscriber.next(this.activeThemeId);
|
||||
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getInstalledThemes(): AppTheme[] {
|
||||
return this.themes.slice();
|
||||
}
|
||||
|
||||
activeThemeId$(): Observable<string | undefined> {
|
||||
return this.observable;
|
||||
}
|
||||
|
||||
getActiveThemeId(): string | undefined {
|
||||
return this.activeThemeId;
|
||||
}
|
||||
|
||||
setActiveThemeId(themeId?: string): void {
|
||||
this.activeThemeId = themeId;
|
||||
this.subscribers.forEach((subscriber) => subscriber.next(themeId));
|
||||
}
|
||||
}
|
||||
@@ -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 * from './AppThemeSelector';
|
||||
@@ -18,6 +18,7 @@
|
||||
//
|
||||
// Plugins should rely on these APIs for functionality as much as possible.
|
||||
|
||||
export * from './AppThemeSelector';
|
||||
export * from './AlertApiForwarder';
|
||||
export * from './ErrorApiForwarder';
|
||||
export * from './OAuthRequestManager';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BackstagePlugin } from '../plugin';
|
||||
import { FeatureFlagsRegistryItem } from './FeatureFlags';
|
||||
import { featureFlagsApiRef } from '../apis/definitions';
|
||||
import ErrorPage from '../../layout/ErrorPage';
|
||||
import { AppThemeProvider } from './AppThemeProvider';
|
||||
|
||||
import {
|
||||
IconComponent,
|
||||
@@ -29,14 +30,24 @@ import {
|
||||
SystemIconKey,
|
||||
defaultSystemIcons,
|
||||
} from '../../icons';
|
||||
import { ApiHolder, ApiProvider, ApiRegistry } from '../apis';
|
||||
import {
|
||||
ApiHolder,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
AppTheme,
|
||||
AppThemeSelector,
|
||||
appThemeApiRef,
|
||||
} from '../apis';
|
||||
import LoginPage from './LoginPage';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { ApiAggregator } from '../apis/ApiAggregator';
|
||||
|
||||
type FullAppOptions = {
|
||||
apis: ApiHolder;
|
||||
icons: SystemIcons;
|
||||
plugins: BackstagePlugin[];
|
||||
components: AppComponents;
|
||||
themes: AppTheme[];
|
||||
};
|
||||
|
||||
class AppImpl implements BackstageApp {
|
||||
@@ -44,12 +55,14 @@ class AppImpl implements BackstageApp {
|
||||
private readonly icons: SystemIcons;
|
||||
private readonly plugins: BackstagePlugin[];
|
||||
private readonly components: AppComponents;
|
||||
private readonly themes: AppTheme[];
|
||||
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
this.icons = options.icons;
|
||||
this.plugins = options.plugins;
|
||||
this.components = options.components;
|
||||
this.themes = options.themes;
|
||||
}
|
||||
|
||||
getApis(): ApiHolder {
|
||||
@@ -129,23 +142,28 @@ class AppImpl implements BackstageApp {
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
);
|
||||
|
||||
let rendered = (
|
||||
const rendered = (
|
||||
<Switch>
|
||||
{routes}
|
||||
<Route component={NotFoundErrorPage} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
if (this.apis) {
|
||||
rendered = <ApiProvider apis={this.apis} children={rendered} />;
|
||||
}
|
||||
|
||||
return () => rendered;
|
||||
}
|
||||
|
||||
getProvider(): ComponentType<{}> {
|
||||
const appApis = ApiRegistry.from([
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
]);
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
|
||||
const Provider: FC<{}> = ({ children }) => (
|
||||
<AppContextProvider app={this}>{children}</AppContextProvider>
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{children}</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
return Provider;
|
||||
}
|
||||
@@ -178,8 +196,22 @@ export function createApp(options?: AppOptions) {
|
||||
NotFoundErrorPage: DefaultNotFoundPage,
|
||||
...options?.components,
|
||||
};
|
||||
const themes = options?.themes ?? [
|
||||
{
|
||||
id: 'light',
|
||||
title: 'Light Theme',
|
||||
variant: 'light',
|
||||
theme: lightTheme,
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
title: 'Dark Theme',
|
||||
variant: 'dark',
|
||||
theme: darkTheme,
|
||||
},
|
||||
];
|
||||
|
||||
const app = new AppImpl({ apis, icons, plugins, components });
|
||||
const app = new AppImpl({ apis, icons, plugins, components, themes });
|
||||
|
||||
app.verify();
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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, useMemo, useEffect, useState } from 'react';
|
||||
import { ThemeProvider, CssBaseline } from '@material-ui/core';
|
||||
import { useApi, appThemeApiRef, AppTheme } from '../apis';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
// This tries to find the most accurate match, but also falls back to less
|
||||
// accurate results in order to avoid errors.
|
||||
function resolveTheme(
|
||||
themeId: string | undefined,
|
||||
shouldPreferDark: boolean,
|
||||
themes: AppTheme[],
|
||||
) {
|
||||
if (themeId !== undefined) {
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||
if (selectedTheme) {
|
||||
return selectedTheme;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPreferDark) {
|
||||
const darkTheme = themes.find((theme) => theme.variant === 'dark');
|
||||
if (darkTheme) {
|
||||
return darkTheme;
|
||||
}
|
||||
}
|
||||
|
||||
const lightTheme = themes.find((theme) => theme.variant === 'light');
|
||||
if (lightTheme) {
|
||||
return lightTheme;
|
||||
}
|
||||
|
||||
return themes[0];
|
||||
}
|
||||
|
||||
const useShouldPreferDarkTheme = () => {
|
||||
if (!window.matchMedia) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mediaQuery = useMemo(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)'),
|
||||
[],
|
||||
);
|
||||
const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setPrefersDark(event.matches);
|
||||
};
|
||||
mediaQuery.addListener(listener);
|
||||
return () => {
|
||||
mediaQuery.removeListener(listener);
|
||||
};
|
||||
}, [mediaQuery]);
|
||||
|
||||
return shouldPreferDark;
|
||||
};
|
||||
|
||||
export const AppThemeProvider: FC<{}> = ({ children }) => {
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
const shouldPreferDark = useShouldPreferDarkTheme();
|
||||
const themeId = useObservable(
|
||||
appThemeApi.activeThemeId$(),
|
||||
appThemeApi.getActiveThemeId(),
|
||||
);
|
||||
|
||||
const appTheme = resolveTheme(
|
||||
themeId,
|
||||
shouldPreferDark,
|
||||
appThemeApi.getInstalledThemes(),
|
||||
);
|
||||
if (!appTheme) {
|
||||
throw new Error('App has no themes');
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={appTheme.theme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import { GitHub as GitHubIcon } from '@material-ui/icons';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import Page from '../../../layout/Page';
|
||||
import Header from '../../../layout/Header';
|
||||
import Content from '../../../layout/Content/Content';
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ComponentType } from 'react';
|
||||
import { IconComponent, SystemIconKey, SystemIcons } from '../../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { ApiHolder } from '../apis';
|
||||
import { AppTheme } from '../apis/definitions';
|
||||
|
||||
export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
@@ -45,6 +46,28 @@ export type AppOptions = {
|
||||
* Supply components to the app to override the default ones.
|
||||
*/
|
||||
components?: Partial<AppComponents>;
|
||||
|
||||
/**
|
||||
* Themes provided as a part of the app. By default two themes are included, one
|
||||
* light variant of the default backstage theme, and one dark.
|
||||
*
|
||||
* This is the default config:
|
||||
*
|
||||
* ```
|
||||
* [{
|
||||
* id: 'light',
|
||||
* title: 'Light Theme',
|
||||
* variant: 'light',
|
||||
* theme: lightTheme,
|
||||
* }, {
|
||||
* id: 'dark',
|
||||
* title: 'Dark Theme',
|
||||
* variant: 'dark',
|
||||
* theme: darkTheme,
|
||||
* }]
|
||||
* ```
|
||||
*/
|
||||
themes?: AppTheme[];
|
||||
};
|
||||
|
||||
export type BackstageApp = {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const Overflow = () => (
|
||||
</>
|
||||
);
|
||||
|
||||
export const Laguages = () => (
|
||||
export const Languages = () => (
|
||||
<>
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
|
||||
<CodeSnippet text={TYPESCRIPT} language="typescript" showLineNumbers />
|
||||
|
||||
@@ -27,23 +27,21 @@ import { BackstageTheme } from '@backstage/theme';
|
||||
import { makeStyles, useTheme } from '@material-ui/core';
|
||||
|
||||
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
|
||||
import {
|
||||
AddBox,
|
||||
ArrowUpward,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clear,
|
||||
DeleteOutline,
|
||||
Edit,
|
||||
FilterList,
|
||||
FirstPage,
|
||||
LastPage,
|
||||
Remove,
|
||||
SaveAlt,
|
||||
Search,
|
||||
ViewColumn,
|
||||
} from '@material-ui/icons';
|
||||
import AddBox from '@material-ui/icons/AddBox';
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward';
|
||||
import Check from '@material-ui/icons/Check';
|
||||
import ChevronLeft from '@material-ui/icons/ChevronLeft';
|
||||
import ChevronRight from '@material-ui/icons/ChevronRight';
|
||||
import Clear from '@material-ui/icons/Clear';
|
||||
import DeleteOutline from '@material-ui/icons/DeleteOutline';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import FilterList from '@material-ui/icons/FilterList';
|
||||
import FirstPage from '@material-ui/icons/FirstPage';
|
||||
import LastPage from '@material-ui/icons/LastPage';
|
||||
import Remove from '@material-ui/icons/Remove';
|
||||
import SaveAlt from '@material-ui/icons/SaveAlt';
|
||||
import Search from '@material-ui/icons/Search';
|
||||
import ViewColumn from '@material-ui/icons/ViewColumn';
|
||||
|
||||
const tableIcons = {
|
||||
Add: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
import LightIcon from '@material-ui/icons/WbSunny';
|
||||
import DarkIcon from '@material-ui/icons/Brightness2';
|
||||
import AutoIcon from '@material-ui/icons/BrightnessAuto';
|
||||
import { appThemeApiRef, useApi } from '../../api';
|
||||
import { SidebarItem } from './Items';
|
||||
|
||||
export const SidebarThemeToggle: FC<{}> = () => {
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
const themeId = useObservable(
|
||||
appThemeApi.activeThemeId$(),
|
||||
appThemeApi.getActiveThemeId(),
|
||||
);
|
||||
|
||||
let text = 'Auto';
|
||||
let icon = AutoIcon;
|
||||
switch (themeId) {
|
||||
case 'dark':
|
||||
text = 'Dark mode';
|
||||
icon = DarkIcon;
|
||||
break;
|
||||
case 'light':
|
||||
text = 'Light mode';
|
||||
icon = LightIcon;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!themeId) {
|
||||
appThemeApi.setActiveThemeId('light');
|
||||
} else if (themeId === 'light') {
|
||||
appThemeApi.setActiveThemeId('dark');
|
||||
} else {
|
||||
appThemeApi.setActiveThemeId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return <SidebarItem text={text} onClick={handleToggle} icon={icon} />;
|
||||
};
|
||||
@@ -18,3 +18,4 @@ export * from './Bar';
|
||||
export * from './Page';
|
||||
export * from './Items';
|
||||
export * from './config';
|
||||
export { SidebarThemeToggle } from './SidebarThemeToggle';
|
||||
|
||||
@@ -18,7 +18,6 @@ import React, { FC, ComponentType } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import BookmarkIcon from '@material-ui/icons/Bookmark';
|
||||
import { ThemeProvider, CssBaseline } from '@material-ui/core';
|
||||
import {
|
||||
createApp,
|
||||
SidebarPage,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
ApiTestRegistry,
|
||||
ApiHolder,
|
||||
} from '@backstage/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import * as defaultApiFactories from './apiFactories';
|
||||
|
||||
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
|
||||
@@ -78,16 +76,12 @@ class DevAppBuilder {
|
||||
const DevApp: FC<{}> = () => {
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,12 +21,17 @@ import { Overrides } from '@material-ui/core/styles/overrides';
|
||||
import {
|
||||
BackstageTheme,
|
||||
BackstageThemeOptions,
|
||||
BackstagePaletteOptions,
|
||||
SimpleThemeOptions,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_FONT_FAMILY =
|
||||
'"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif';
|
||||
|
||||
export function createThemeOptions(
|
||||
palette: BackstagePaletteOptions,
|
||||
options: SimpleThemeOptions,
|
||||
): BackstageThemeOptions {
|
||||
const { palette, fontFamily = DEFAULT_FONT_FAMILY } = options;
|
||||
|
||||
return {
|
||||
palette,
|
||||
props: {
|
||||
@@ -38,7 +43,7 @@ export function createThemeOptions(
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif',
|
||||
fontFamily,
|
||||
h5: {
|
||||
fontWeight: 700,
|
||||
},
|
||||
@@ -205,8 +210,8 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
|
||||
|
||||
// Creates a Backstage MUI theme using a palette.
|
||||
// The theme is created with the common Backstage options and component styles.
|
||||
export function createTheme(palette: BackstagePaletteOptions): BackstageTheme {
|
||||
const themeOptions = createThemeOptions(palette);
|
||||
export function createTheme(options: SimpleThemeOptions): BackstageTheme {
|
||||
const themeOptions = createThemeOptions(options);
|
||||
const baseTheme = createMuiTheme(themeOptions) as BackstageTheme;
|
||||
const overrides = createThemeOverrides(baseTheme);
|
||||
const theme = { ...baseTheme, overrides };
|
||||
|
||||
@@ -18,81 +18,85 @@ import { createTheme } from './baseTheme';
|
||||
import { blue, yellow } from '@material-ui/core/colors';
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
type: 'light',
|
||||
background: {
|
||||
default: '#F8F8F8',
|
||||
},
|
||||
status: {
|
||||
ok: '#1DB954',
|
||||
warning: '#FF9800',
|
||||
error: '#E22134',
|
||||
running: '#2E77D0',
|
||||
pending: '#FFED51',
|
||||
aborted: '#757575',
|
||||
},
|
||||
bursts: {
|
||||
fontColor: '#FEFEFE',
|
||||
slackChannelText: '#ddd',
|
||||
backgroundColor: {
|
||||
default: '#7C3699',
|
||||
palette: {
|
||||
type: 'light',
|
||||
background: {
|
||||
default: '#F8F8F8',
|
||||
},
|
||||
status: {
|
||||
ok: '#1DB954',
|
||||
warning: '#FF9800',
|
||||
error: '#E22134',
|
||||
running: '#2E77D0',
|
||||
pending: '#FFED51',
|
||||
aborted: '#757575',
|
||||
},
|
||||
bursts: {
|
||||
fontColor: '#FEFEFE',
|
||||
slackChannelText: '#ddd',
|
||||
backgroundColor: {
|
||||
default: '#7C3699',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
main: blue[500],
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#000000',
|
||||
textVerySubtle: '#DDD',
|
||||
textSubtle: '#6E6E6E',
|
||||
highlight: '#FFFBCC',
|
||||
errorBackground: '#FFEBEE',
|
||||
warningBackground: '#F59B23',
|
||||
infoBackground: '#ebf5ff',
|
||||
errorText: '#CA001B',
|
||||
infoText: '#004e8a',
|
||||
warningText: '#FEFEFE',
|
||||
linkHover: '#2196F3',
|
||||
link: '#0A6EBE',
|
||||
gold: yellow.A700,
|
||||
sidebar: '#171717',
|
||||
},
|
||||
primary: {
|
||||
main: blue[500],
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#000000',
|
||||
textVerySubtle: '#DDD',
|
||||
textSubtle: '#6E6E6E',
|
||||
highlight: '#FFFBCC',
|
||||
errorBackground: '#FFEBEE',
|
||||
warningBackground: '#F59B23',
|
||||
infoBackground: '#ebf5ff',
|
||||
errorText: '#CA001B',
|
||||
infoText: '#004e8a',
|
||||
warningText: '#FEFEFE',
|
||||
linkHover: '#2196F3',
|
||||
link: '#0A6EBE',
|
||||
gold: yellow.A700,
|
||||
sidebar: '#171717',
|
||||
});
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: '#333333',
|
||||
},
|
||||
status: {
|
||||
ok: '#1DB954',
|
||||
warning: '#FF9800',
|
||||
error: '#E22134',
|
||||
running: '#2E77D0',
|
||||
pending: '#FFED51',
|
||||
aborted: '#757575',
|
||||
},
|
||||
bursts: {
|
||||
fontColor: '#FEFEFE',
|
||||
slackChannelText: '#ddd',
|
||||
backgroundColor: {
|
||||
default: '#7C3699',
|
||||
palette: {
|
||||
type: 'dark',
|
||||
background: {
|
||||
default: '#333333',
|
||||
},
|
||||
status: {
|
||||
ok: '#1DB954',
|
||||
warning: '#FF9800',
|
||||
error: '#E22134',
|
||||
running: '#2E77D0',
|
||||
pending: '#FFED51',
|
||||
aborted: '#757575',
|
||||
},
|
||||
bursts: {
|
||||
fontColor: '#FEFEFE',
|
||||
slackChannelText: '#ddd',
|
||||
backgroundColor: {
|
||||
default: '#7C3699',
|
||||
},
|
||||
},
|
||||
primary: {
|
||||
main: blue[500],
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#FFFFFF',
|
||||
textVerySubtle: '#DDD',
|
||||
textSubtle: '#EEEEEE',
|
||||
highlight: '#FFFBCC',
|
||||
errorBackground: '#FFEBEE',
|
||||
warningBackground: '#F59B23',
|
||||
infoBackground: '#ebf5ff',
|
||||
errorText: '#CA001B',
|
||||
infoText: '#004e8a',
|
||||
warningText: '#FEFEFE',
|
||||
linkHover: '#2196F3',
|
||||
link: '#0A6EBE',
|
||||
gold: yellow.A700,
|
||||
sidebar: '#424242',
|
||||
},
|
||||
primary: {
|
||||
main: blue[500],
|
||||
},
|
||||
border: '#E6E6E6',
|
||||
textContrast: '#FFFFFF',
|
||||
textVerySubtle: '#DDD',
|
||||
textSubtle: '#EEEEEE',
|
||||
highlight: '#FFFBCC',
|
||||
errorBackground: '#FFEBEE',
|
||||
warningBackground: '#F59B23',
|
||||
infoBackground: '#ebf5ff',
|
||||
errorText: '#CA001B',
|
||||
infoText: '#004e8a',
|
||||
warningText: '#FEFEFE',
|
||||
linkHover: '#2196F3',
|
||||
link: '#0A6EBE',
|
||||
gold: yellow.A700,
|
||||
sidebar: '#424242',
|
||||
});
|
||||
|
||||
@@ -63,3 +63,11 @@ export interface BackstageTheme extends Theme {
|
||||
export interface BackstageThemeOptions extends ThemeOptions {
|
||||
palette: BackstagePaletteOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simpler configuration for creating a new theme that just tweaks some parts of the backstage one.
|
||||
*/
|
||||
export type SimpleThemeOptions = {
|
||||
palette: BackstagePaletteOptions;
|
||||
fontFamily?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
# Auth Backend
|
||||
|
||||
WORK IN PROGRESS
|
||||
|
||||
This is the backend part of the auth plugin.
|
||||
|
||||
It responds to auth requests from the frontend, and fulfills them by delegating
|
||||
to the appropriate provider in the backend.
|
||||
|
||||
## Links
|
||||
|
||||
- (The Backstage homepage)[https://backstage.io]
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend",
|
||||
"version": "0.1.1-alpha.5",
|
||||
"main": "dist",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"",
|
||||
"build": "tsc",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.5",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
"helmet": "^3.22.0",
|
||||
"morgan": "^1.10.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"tsc-watch": "^4.2.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"nodemonConfig": {
|
||||
"watch": "./dist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
describe('test', () => {
|
||||
it('unbreaks the test runner', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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 * from './service/router';
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 yn from 'yn';
|
||||
import { getRootLogger } from '@backstage/backend-common';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('CTRL+C pressed; exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -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 express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const logger = options.logger.child({ plugin: 'auth' });
|
||||
const router = Router();
|
||||
|
||||
router.get('/ping', async (req, res) => {
|
||||
res.status(200).send('pong');
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.set('logger', logger);
|
||||
app.use(router);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 {
|
||||
errorHandler,
|
||||
notFoundHandler,
|
||||
requestLoggingHandler,
|
||||
} from '@backstage/backend-common';
|
||||
import compression from 'compression';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'winston';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function createStandaloneApplication(
|
||||
options: ApplicationOptions,
|
||||
): Promise<express.Application> {
|
||||
const { enableCors, logger } = options;
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
if (enableCors) {
|
||||
app.use(cors());
|
||||
}
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use('/', await createRouter({ logger }));
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { createStandaloneApplication } from './standaloneApplication';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
enableCors: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
options: ServerOptions,
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'auth-backend' });
|
||||
|
||||
logger.debug('Creating application...');
|
||||
const app = await createStandaloneApplication({
|
||||
enableCors: options.enableCors,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.debug('Starting application server...');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = app.listen(options.port, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Listening on port ${options.port}`);
|
||||
resolve(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"lib": ["es2019"],
|
||||
"types": ["node", "jest"]
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
"@backstage/dev-utils": "^0.1.1-alpha.5",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.5",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
|
||||
@@ -19,12 +19,19 @@ import { render } from '@testing-library/react';
|
||||
import CatalogPage from './CatalogPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
|
||||
const testComponentFactory: ComponentFactory = {
|
||||
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
|
||||
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
|
||||
removeComponentByName: jest.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
it('should render', async () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CatalogPage />
|
||||
<CatalogPage componentFactory={testComponentFactory} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(await rendered.findByText('Your components')).toBeInTheDocument();
|
||||
|
||||
@@ -18,12 +18,12 @@ import React, { FC } from 'react';
|
||||
import { Content, Header, Page, pageTheme } from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
import { MockComponentFactory } from '../../data/mock-factory';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
|
||||
const componentFactory: ComponentFactory = MockComponentFactory;
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
type CatalogPageProps = {
|
||||
componentFactory: ComponentFactory;
|
||||
};
|
||||
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Component } from '../../data/component';
|
||||
import { InfoCard, Progress, Table, TableColumn } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { Typography, Link } from '@material-ui/core';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Name',
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (componentData: any) => (
|
||||
<Link href={`/catalog/${componentData.name}`}>{componentData.name}</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 ComponentContextMenu from './ComponentContextMenu';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
describe('ComponentContextMenu', () => {
|
||||
it('should call onUnregisterComponent on button click', async () => {
|
||||
await act(async () => {
|
||||
const mockCallback = jest.fn();
|
||||
const menu = await render(
|
||||
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
|
||||
);
|
||||
const button = await menu.findByTestId('menu-button');
|
||||
button.click();
|
||||
const unregister = await menu.findByText('Unregister component');
|
||||
expect(unregister).toBeInTheDOM();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import SwapHoriz from '@material-ui/icons/SwapHoriz';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
menu: {
|
||||
marginTop: 52,
|
||||
},
|
||||
});
|
||||
|
||||
type ComponentContextMenuProps = {
|
||||
onUnregisterComponent: () => void;
|
||||
};
|
||||
|
||||
const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
|
||||
onUnregisterComponent,
|
||||
}) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuAnchor = useRef<HTMLDivElement>(null);
|
||||
const classes = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const globalCloseHandler = (event: any) => {
|
||||
const menu = menuAnchor.current;
|
||||
if (menu !== null && !menu.contains(event.target)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('click', globalCloseHandler);
|
||||
return () => window.removeEventListener('click', globalCloseHandler);
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div ref={menuAnchor}>
|
||||
<IconButton
|
||||
aria-label="more"
|
||||
aria-controls="long-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
data-testid="menu-button"
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
anchorEl={menuAnchor.current}
|
||||
className={classes.menu}
|
||||
>
|
||||
<MenuItem onClick={onUnregisterComponent}>
|
||||
<ListItemIcon>
|
||||
<Cancel fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Unregister component</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<ListItemIcon>
|
||||
<SwapHoriz fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">More repository</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ComponentContextMenu;
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 ComponentMetadataCard from './ComponentMetadataCard';
|
||||
import { Component } from '../../data/component';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
describe('ComponentMetadataCard component', () => {
|
||||
it('should display component name if provided', async () => {
|
||||
const testComponent: Component = {
|
||||
name: 'test',
|
||||
};
|
||||
const rendered = await render(
|
||||
<ComponentMetadataCard loading={false} component={testComponent} />,
|
||||
);
|
||||
expect(await rendered.findByText('test')).toBeInTheDOM();
|
||||
});
|
||||
it('should display loader when loading is set to true', async () => {
|
||||
const rendered = await render(
|
||||
<ComponentMetadataCard loading component={undefined} />,
|
||||
);
|
||||
expect(await rendered.findByRole('progressbar')).toBeInTheDOM();
|
||||
});
|
||||
});
|
||||
@@ -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, { FC } from 'react';
|
||||
import { Component } from '../../data/component';
|
||||
import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
|
||||
type ComponentMetadataCardProps = {
|
||||
loading: boolean;
|
||||
component: Component | undefined;
|
||||
};
|
||||
const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
|
||||
loading,
|
||||
component,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
}
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<InfoCard title="Metadata">
|
||||
<StructuredMetadataTable metadata={component} />
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
export default ComponentMetadataCard;
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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 ComponentPage from './ComponentPage';
|
||||
import { render } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { wrapInTheme } from '@backstage/test-utils';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
|
||||
|
||||
const getTestProps = (componentName: string) => {
|
||||
return {
|
||||
match: {
|
||||
params: {
|
||||
name: componentName,
|
||||
},
|
||||
},
|
||||
history: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
componentFactory: {
|
||||
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
|
||||
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
|
||||
removeComponentByName: jest.fn(() => Promise.resolve(true)),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
|
||||
describe('ComponentPage', () => {
|
||||
it('should redirect to component table page when name is not provided', async () => {
|
||||
const props = getTestProps('');
|
||||
await render(
|
||||
wrapInTheme(
|
||||
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
|
||||
<ComponentPage {...props} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(props.history.push).toHaveBeenCalledWith('/catalog');
|
||||
});
|
||||
it('should use factory to fetch component by name and display it', async () => {
|
||||
await act(async () => {
|
||||
const props = getTestProps('test');
|
||||
await render(
|
||||
wrapInTheme(
|
||||
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
|
||||
<ComponentPage {...props} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
expect(props.componentFactory.getComponentByName).toHaveBeenCalledWith(
|
||||
'test',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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, useEffect, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard';
|
||||
import {
|
||||
Content,
|
||||
Header,
|
||||
pageTheme,
|
||||
Page,
|
||||
useApi,
|
||||
ErrorApi,
|
||||
errorApiRef,
|
||||
} from '@backstage/core';
|
||||
import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
|
||||
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
|
||||
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
type ComponentPageProps = {
|
||||
componentFactory: ComponentFactory;
|
||||
match: {
|
||||
params: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
history: {
|
||||
push: (url: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const ComponentPage: FC<ComponentPageProps> = ({
|
||||
match,
|
||||
history,
|
||||
componentFactory,
|
||||
}) => {
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
const [removingPending, setRemovingPending] = useState(false);
|
||||
const showRemovalDialog = () => setConfirmationDialogOpen(true);
|
||||
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
|
||||
const componentName = match.params.name;
|
||||
const errorApi = useApi<ErrorApi>(errorApiRef);
|
||||
|
||||
if (componentName === '') {
|
||||
history.push('/catalog');
|
||||
return null;
|
||||
}
|
||||
|
||||
const catalogRequest = useAsync(() =>
|
||||
componentFactory.getComponentByName(match.params.name),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogRequest.error) {
|
||||
errorApi.post(new Error('Component not found!'));
|
||||
setTimeout(() => {
|
||||
history.push('/catalog');
|
||||
}, REDIRECT_DELAY);
|
||||
}
|
||||
}, [catalogRequest.error]);
|
||||
|
||||
const removeComponent = async () => {
|
||||
setConfirmationDialogOpen(false);
|
||||
setRemovingPending(true);
|
||||
await componentFactory.removeComponentByName(componentName);
|
||||
history.push('/catalog');
|
||||
};
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title={catalogRequest?.value?.name || 'Catalog'}>
|
||||
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
|
||||
</Header>
|
||||
{confirmationDialogOpen && catalogRequest.value && (
|
||||
<ComponentRemovalDialog
|
||||
component={catalogRequest.value}
|
||||
onClose={hideRemovalDialog}
|
||||
onConfirm={removeComponent}
|
||||
onCancel={hideRemovalDialog}
|
||||
/>
|
||||
)}
|
||||
<Content>
|
||||
<ComponentMetadataCard
|
||||
loading={catalogRequest.loading || removingPending}
|
||||
component={catalogRequest.value}
|
||||
/>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
export default ComponentPage;
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
type ComponentRemovalDialogProps = {
|
||||
onConfirm: () => any;
|
||||
onCancel: () => any;
|
||||
onClose: () => any;
|
||||
component: Component;
|
||||
};
|
||||
const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onClose,
|
||||
component,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
return (
|
||||
<Dialog fullScreen={fullScreen} open onClose={onClose}>
|
||||
<DialogTitle id="responsive-dialog-title">
|
||||
Are you sure you want to unregister this component?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This action will unregister {component.name}. To undo, just
|
||||
re-register the component in Backstage.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} color="primary">
|
||||
Unregister
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default ComponentRemovalDialog;
|
||||
@@ -20,4 +20,5 @@ export type Component = {
|
||||
export interface ComponentFactory {
|
||||
getAllComponents(): Promise<Component[]>;
|
||||
getComponentByName(name: string): Promise<Component | undefined>;
|
||||
removeComponentByName(name: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -16,16 +16,33 @@
|
||||
import { Component, ComponentFactory } from './component';
|
||||
import mock from './mock-factory-data.json';
|
||||
|
||||
const ARTIFICIAL_TIMEOUT = 800;
|
||||
let inMemoryStore = [...mock];
|
||||
export const MockComponentFactory: ComponentFactory = {
|
||||
getAllComponents(): Promise<Component[]> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(mock), 2000));
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
getComponentByName(name: string): Promise<Component | undefined> {
|
||||
return new Promise((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
const mockComponent = inMemoryStore.find(
|
||||
(component) => component.name === name,
|
||||
);
|
||||
if (mockComponent) return resolve(mockComponent);
|
||||
return reject({ code: 'Component not found!' });
|
||||
}, ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
removeComponentByName(name: string): Promise<boolean> {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve(mock.find((component) => component.name === name)),
|
||||
2000,
|
||||
),
|
||||
setTimeout(() => {
|
||||
inMemoryStore = inMemoryStore.filter(
|
||||
(component) => component.name !== name,
|
||||
);
|
||||
resolve(true);
|
||||
}, ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 * as React from 'react';
|
||||
import { ComponentFactory } from './component';
|
||||
import { MockComponentFactory } from './mock-factory';
|
||||
|
||||
const componentFactory: ComponentFactory = MockComponentFactory;
|
||||
|
||||
export const withMockStore = (Component: React.ElementType) => {
|
||||
return (props: any) => (
|
||||
<Component {...props} componentFactory={componentFactory} />
|
||||
);
|
||||
};
|
||||
@@ -16,10 +16,13 @@
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import CatalogPage from './components/CatalogPage';
|
||||
import ComponentPage from './components/ComponentPage/ComponentPage';
|
||||
import { withMockStore } from './data/with-mock-store';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'catalog',
|
||||
register({ router }) {
|
||||
router.registerRoute('/catalog', CatalogPage);
|
||||
router.registerRoute('/catalog', withMockStore(CatalogPage));
|
||||
router.registerRoute('/catalog/:name/', withMockStore(ComponentPage));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4358,9 +4358,9 @@
|
||||
immutable ">=3.8.2"
|
||||
|
||||
"@types/react-router-dom@^5.1.3":
|
||||
version "5.1.3"
|
||||
resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196"
|
||||
integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA==
|
||||
version "5.1.5"
|
||||
resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090"
|
||||
integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw==
|
||||
dependencies:
|
||||
"@types/history" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
Reference in New Issue
Block a user