Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Nikita Nek Dudnik
2020-05-18 09:20:07 +02:00
56 changed files with 1557 additions and 313 deletions
+10 -48
View File
@@ -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;
-69
View File
@@ -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];
}
+2 -2
View File
@@ -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;
+1
View File
@@ -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",
+2
View File
@@ -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());
+22
View File
@@ -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 });
}
+13
View File
@@ -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';
+40 -8
View File
@@ -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';
+23
View File
@@ -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 />
+15 -17
View File
@@ -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';
+6 -12
View File
@@ -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>
);
};
+10 -5
View File
@@ -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 };
+74 -70
View File
@@ -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',
});
+8
View File
@@ -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;
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+12
View File
@@ -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]
+37
View File
@@ -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"
}
}
+21
View File
@@ -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();
});
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './service/router';
+33
View File
@@ -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);
});
});
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
require('jest-fetch-mock').enableMocks();
+15
View File
@@ -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"]
}
}
+1
View File
@@ -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;
+1
View File
@@ -20,4 +20,5 @@ export type Component = {
export interface ComponentFactory {
getAllComponents(): Promise<Component[]>;
getComponentByName(name: string): Promise<Component | undefined>;
removeComponentByName(name: string): Promise<boolean>;
}
+22 -5
View File
@@ -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} />
);
};
+4 -1
View File
@@ -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));
},
});
+3 -3
View File
@@ -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" "*"