Merge pull request #876 from spotify/rugvip/theme
package/core: make themes configurable as a part of app creation, and add API for switching themes
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];
|
||||
}
|
||||
@@ -15,42 +15,47 @@
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { SidebarItem } from '@backstage/core';
|
||||
import { ThemeContext } from '../../ThemeContext';
|
||||
import { useObservable } from 'react-use';
|
||||
import { SidebarItem, useApi, appThemeApiRef } from '@backstage/core';
|
||||
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>
|
||||
const ToggleThemeSidebarItem: FC<{}> = () => {
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
const themeId = useObservable(
|
||||
appThemeApi.activeThemeId$(),
|
||||
appThemeApi.getActiveThemeId(),
|
||||
);
|
||||
|
||||
let text = 'Auto';
|
||||
let icon = ToggleOnIcon;
|
||||
switch (themeId) {
|
||||
case 'dark':
|
||||
text = 'Dark mode';
|
||||
icon = Brightness2Icon;
|
||||
break;
|
||||
case 'light':
|
||||
text = 'Light mode';
|
||||
icon = WbSunnyIcon;
|
||||
break;
|
||||
default:
|
||||
text = 'Auto';
|
||||
icon = ToggleOnIcon;
|
||||
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} />;
|
||||
};
|
||||
|
||||
export default ToggleThemeSidebarItem;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user