From 2d365809f1c965817e2e99b92e45a59001b9a175 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 16:53:03 +0200 Subject: [PATCH 01/14] packages/core: added initial AppThemeApi --- .../src/api/apis/definitions/AppThemeApi.ts | 64 +++++++++++++++++++ .../core/src/api/apis/definitions/index.ts | 1 + 2 files changed, 65 insertions(+) create mode 100644 packages/core/src/api/apis/definitions/AppThemeApi.ts diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts new file mode 100644 index 0000000000..bddb6f1a10 --- /dev/null +++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts @@ -0,0 +1,64 @@ +/* + * 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'; + +/** + * Describes a theme provided by the app. + */ +export type AppTheme = { + /** + * 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. + */ + getThemeOptions(): AppTheme[]; + + /** + * Get the current theme. Returns undefined if the default theme is used. + */ + getTheme(): AppTheme | undefined; + + /** + * Set a specific theme to use in the app, overriding the default theme selection. + */ + setTheme(theme?: AppTheme): void; +}; + +export const appThemeApiRef = new ApiRef({ + id: 'core.apptheme', + description: 'API Used to configure the app theme, and enumerate options', +}); diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts index 9e7f7534b9..2bb365b2ab 100644 --- a/packages/core/src/api/apis/definitions/index.ts +++ b/packages/core/src/api/apis/definitions/index.ts @@ -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'; From 66adb0dfe7ae277039033cbcee82dce94db24dc6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 17:30:56 +0200 Subject: [PATCH 02/14] packages/core: added theme options to createApp --- .../src/api/apis/definitions/AppThemeApi.ts | 5 +++ packages/core/src/api/app/App.tsx | 38 ++++++++++++++++++- packages/core/src/api/app/types.ts | 19 ++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts index bddb6f1a10..f866e38738 100644 --- a/packages/core/src/api/apis/definitions/AppThemeApi.ts +++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts @@ -21,6 +21,11 @@ import { BackstageTheme } from '@backstage/theme'; * Describes a theme provided by the app. */ export type AppTheme = { + /** + * ID used to remember theme selections. + */ + id: string; + /** * Title of the theme */ diff --git a/packages/core/src/api/app/App.tsx b/packages/core/src/api/app/App.tsx index c44454b10e..462734ee4b 100644 --- a/packages/core/src/api/app/App.tsx +++ b/packages/core/src/api/app/App.tsx @@ -29,14 +29,16 @@ import { SystemIconKey, defaultSystemIcons, } from '../../icons'; -import { ApiHolder, ApiProvider, ApiRegistry } from '../apis'; +import { ApiHolder, ApiProvider, ApiRegistry, AppTheme } from '../apis'; import LoginPage from './LoginPage'; +import { lightTheme } from '@backstage/theme'; type FullAppOptions = { apis: ApiHolder; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; + themes: AppTheme[]; }; class AppImpl implements BackstageApp { @@ -44,12 +46,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 { @@ -178,8 +182,38 @@ export function createApp(options?: AppOptions) { NotFoundErrorPage: DefaultNotFoundPage, ...options?.components, }; + const themes = new Array(); - const app = new AppImpl({ apis, icons, plugins, components }); + if (Array.isArray(options?.themes)) { + themes.push(...options?.themes!); + } else { + if (options?.themes?.light) { + themes.push({ + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: options?.themes?.light, + }); + } + if (options?.themes?.dark) { + themes.push({ + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: options?.themes?.dark, + }); + } + } + if (themes.length === 0) { + themes.push({ + id: 'light', + title: 'Default Theme', + variant: 'light', + theme: lightTheme, + }); + } + + const app = new AppImpl({ apis, icons, plugins, components, themes }); app.verify(); diff --git a/packages/core/src/api/app/types.ts b/packages/core/src/api/app/types.ts index e8d597e120..b208b85afb 100644 --- a/packages/core/src/api/app/types.ts +++ b/packages/core/src/api/app/types.ts @@ -15,9 +15,11 @@ */ import { ComponentType } from 'react'; +import { BackstageTheme } from '@backstage/theme'; 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 +47,23 @@ export type AppOptions = { * Supply components to the app to override the default ones. */ components?: Partial; + + /** + * Themes provided as a part of the app. + * + * The themes can be specified either as just default light and dark mode themes. + * If only one of then is specified then that theme will always be used. + * If both are provided the default theme will be set based on browser preferences. + * + * If an array of themes is provided, the first occurence of light and dark mode variants + * will be treated as the default for each variant. + */ + themes?: + | { + light?: BackstageTheme; + dark?: BackstageTheme; + } + | AppTheme[]; }; export type BackstageApp = { From 5de9517d2cfed1a9e1c5d52cc3a9af02f08e8dc0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 21:32:31 +0200 Subject: [PATCH 03/14] packages/core: tweak AppThemeApi and add AppThemeProvider --- .../src/api/apis/definitions/AppThemeApi.ts | 14 +++- .../core/src/api/app/AppThemeProvider.tsx | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/api/app/AppThemeProvider.tsx diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts index f866e38738..801210de0a 100644 --- a/packages/core/src/api/apis/definitions/AppThemeApi.ts +++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts @@ -16,6 +16,7 @@ import { ApiRef } from '../ApiRef'; import { BackstageTheme } from '@backstage/theme'; +import { Observable } from '../../types'; /** * Describes a theme provided by the app. @@ -53,14 +54,21 @@ export type AppThemeApi = { getThemeOptions(): AppTheme[]; /** - * Get the current theme. Returns undefined if the default theme is used. + * Observe the currently selected theme. A value of undefined means no specific theme has been selected. */ - getTheme(): AppTheme | undefined; + activeThemeId$(): Observable; + + /** + * 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. */ - setTheme(theme?: AppTheme): void; + setActiveThemeId(themeId?: string): void; }; export const appThemeApiRef = new ApiRef({ diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx new file mode 100644 index 0000000000..487a79889f --- /dev/null +++ b/packages/core/src/api/app/AppThemeProvider.tsx @@ -0,0 +1,67 @@ +/* + * 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 { 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, themes: AppTheme[]) { + if (themeId !== undefined) { + const selectedTheme = themes.find((theme) => theme.id === themeId); + if (selectedTheme) { + return selectedTheme; + } + } + + const preferDark = window?.matchMedia('(prefers-color-scheme: dark)') + ?.matches; + + if (preferDark) { + 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]; +} + +export const AppThemeProvider: FC<{}> = ({ children }) => { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + const theme = resolveTheme(themeId, appThemeApi.getInstalledThemes()); + if (!theme) { + throw new Error('App has no themes'); + } + + return ( + + {children} + + ); +}; From b8e3f9736ee4d2d6271c28460340b88ec22d74b6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 00:09:23 +0200 Subject: [PATCH 04/14] packages/core: added ApiAggregator class --- .../core/src/api/apis/ApiAggregator.test.ts | 46 +++++++++++++++++++ packages/core/src/api/apis/ApiAggregator.ts | 40 ++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 packages/core/src/api/apis/ApiAggregator.test.ts create mode 100644 packages/core/src/api/apis/ApiAggregator.ts diff --git a/packages/core/src/api/apis/ApiAggregator.test.ts b/packages/core/src/api/apis/ApiAggregator.test.ts new file mode 100644 index 0000000000..a230e0cfff --- /dev/null +++ b/packages/core/src/api/apis/ApiAggregator.test.ts @@ -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({ id: 'a', description: '' }); + const apiBRef = new ApiRef({ 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); + }); +}); diff --git a/packages/core/src/api/apis/ApiAggregator.ts b/packages/core/src/api/apis/ApiAggregator.ts new file mode 100644 index 0000000000..da49ee6488 --- /dev/null +++ b/packages/core/src/api/apis/ApiAggregator.ts @@ -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(apiRef: ApiRef): T | undefined { + for (const holder of this.holders) { + const api = holder.get(apiRef); + if (api) { + return api; + } + } + return undefined; + } +} From 05ac0bef26d88a73d6c0dbde38f0737e7c6cf1db Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 00:46:59 +0200 Subject: [PATCH 05/14] packages/core: added AppThemeSelector --- .../src/api/apis/definitions/AppThemeApi.ts | 2 +- .../AppThemeSelector/AppThemeSelector.test.ts | 48 +++++++++++++++ .../AppThemeSelector/AppThemeSelector.ts | 59 +++++++++++++++++++ .../implementations/AppThemeSelector/index.ts | 17 ++++++ .../src/api/apis/implementations/index.ts | 1 + 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts create mode 100644 packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts create mode 100644 packages/core/src/api/apis/implementations/AppThemeSelector/index.ts diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts index 801210de0a..738bae8a9f 100644 --- a/packages/core/src/api/apis/definitions/AppThemeApi.ts +++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts @@ -51,7 +51,7 @@ export type AppThemeApi = { /** * Get a list of available themes. */ - getThemeOptions(): AppTheme[]; + getInstalledThemes(): AppTheme[]; /** * Observe the currently selected theme. A value of undefined means no specific theme has been selected. diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts new file mode 100644 index 0000000000..6c6c9774bd --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts @@ -0,0 +1,48 @@ +/* + * 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(); + const selector = new AppThemeSelector(themes); + + expect(selector.getInstalledThemes()).toEqual(themes); + expect(selector.getInstalledThemes()).not.toBe(themes); + }); +}); diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts new file mode 100644 index 0000000000..97343f8d1b --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts @@ -0,0 +1,59 @@ +/* + * 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'; + +export class AppThemeSelector implements AppThemeApi { + private readonly themes: AppTheme[]; + + private activeThemeId: string | undefined; + + private readonly observable: Observable; + private readonly subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + 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 { + return this.observable; + } + + getActiveThemeId(): string | undefined { + return this.activeThemeId; + } + + setActiveThemeId(themeId?: string): void { + this.activeThemeId = themeId; + this.subscribers.forEach((subscriber) => subscriber.next(themeId)); + } +} diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts new file mode 100644 index 0000000000..cb42c0f875 --- /dev/null +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts @@ -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'; diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts index ebd89b54c6..8334b068fc 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core/src/api/apis/implementations/index.ts @@ -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'; From 152aa51a2821816eaabb232ba7316286f7c86435 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 01:03:56 +0200 Subject: [PATCH 06/14] packages/core: added localStorage support for AppThemeSelector --- .../AppThemeSelector/AppThemeSelector.test.ts | 37 +++++++++++++++++++ .../AppThemeSelector/AppThemeSelector.ts | 28 ++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts index 6c6c9774bd..0c23fe5219 100644 --- a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts @@ -45,4 +45,41 @@ describe('AppThemeSelector', () => { 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'); + }); }); diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts index 97343f8d1b..59bc746c41 100644 --- a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts @@ -17,7 +17,35 @@ 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); + + 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; From 75733e2857c75c866c9cc0884cee112ef5eb2ca3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 01:18:56 +0200 Subject: [PATCH 07/14] packages/core: make AppThemeProvider listen for media query changes --- .../core/src/api/app/AppThemeProvider.tsx | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx index 487a79889f..07de903dd5 100644 --- a/packages/core/src/api/app/AppThemeProvider.tsx +++ b/packages/core/src/api/app/AppThemeProvider.tsx @@ -14,14 +14,18 @@ * limitations under the License. */ -import React, { FC } from 'react'; +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, themes: AppTheme[]) { +function resolveTheme( + themeId: string | undefined, + preferDark: boolean, + themes: AppTheme[], +) { if (themeId !== undefined) { const selectedTheme = themes.find((theme) => theme.id === themeId); if (selectedTheme) { @@ -29,9 +33,6 @@ function resolveTheme(themeId: string | undefined, themes: AppTheme[]) { } } - const preferDark = window?.matchMedia('(prefers-color-scheme: dark)') - ?.matches; - if (preferDark) { const darkTheme = themes.find((theme) => theme.variant === 'dark'); if (darkTheme) { @@ -47,14 +48,43 @@ function resolveTheme(themeId: string | undefined, themes: AppTheme[]) { return themes[0]; } +const usePrefersDarkTheme = () => { + if (!window.matchMedia) { + return false; + } + + const mediaQuery = useMemo( + () => window.matchMedia('(prefers-color-scheme: dark)'), + [], + ); + const [preferDark, setPrefersDark] = useState(mediaQuery.matches); + + useEffect(() => { + const listener = (event: MediaQueryListEvent) => { + setPrefersDark(event.matches); + }; + mediaQuery.addListener(listener); + return () => { + mediaQuery.removeListener(listener); + }; + }, []); + + return preferDark; +}; + export const AppThemeProvider: FC<{}> = ({ children }) => { const appThemeApi = useApi(appThemeApiRef); + const preferDark = usePrefersDarkTheme(); const themeId = useObservable( appThemeApi.activeThemeId$(), appThemeApi.getActiveThemeId(), ); - const theme = resolveTheme(themeId, appThemeApi.getInstalledThemes()); + const theme = resolveTheme( + themeId, + preferDark, + appThemeApi.getInstalledThemes(), + ); if (!theme) { throw new Error('App has no themes'); } From d9f0d734cfeedfc132a8f516aa0575c4a82369ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 09:25:18 +0200 Subject: [PATCH 08/14] packages/core: simplify app theme configuration --- packages/core/src/api/app/App.tsx | 38 +++++++++--------------------- packages/core/src/api/app/types.ts | 30 +++++++++++++---------- 2 files changed, 28 insertions(+), 40 deletions(-) diff --git a/packages/core/src/api/app/App.tsx b/packages/core/src/api/app/App.tsx index 462734ee4b..50eaac6a2c 100644 --- a/packages/core/src/api/app/App.tsx +++ b/packages/core/src/api/app/App.tsx @@ -182,36 +182,20 @@ export function createApp(options?: AppOptions) { NotFoundErrorPage: DefaultNotFoundPage, ...options?.components, }; - const themes = new Array(); - - if (Array.isArray(options?.themes)) { - themes.push(...options?.themes!); - } else { - if (options?.themes?.light) { - themes.push({ - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: options?.themes?.light, - }); - } - if (options?.themes?.dark) { - themes.push({ - id: 'dark', - title: 'Dark Theme', - variant: 'dark', - theme: options?.themes?.dark, - }); - } - } - if (themes.length === 0) { - themes.push({ + const themes = options?.themes ?? [ + { id: 'light', - title: 'Default Theme', + title: 'Light Theme', variant: 'light', theme: lightTheme, - }); - } + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: darkTheme, + }, + ]; const app = new AppImpl({ apis, icons, plugins, components, themes }); diff --git a/packages/core/src/api/app/types.ts b/packages/core/src/api/app/types.ts index b208b85afb..75a750ce6e 100644 --- a/packages/core/src/api/app/types.ts +++ b/packages/core/src/api/app/types.ts @@ -15,7 +15,6 @@ */ import { ComponentType } from 'react'; -import { BackstageTheme } from '@backstage/theme'; import { IconComponent, SystemIconKey, SystemIcons } from '../../icons'; import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; @@ -49,21 +48,26 @@ export type AppOptions = { components?: Partial; /** - * Themes provided as a part of the app. + * 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. * - * The themes can be specified either as just default light and dark mode themes. - * If only one of then is specified then that theme will always be used. - * If both are provided the default theme will be set based on browser preferences. + * This is the default config: * - * If an array of themes is provided, the first occurence of light and dark mode variants - * will be treated as the default for each variant. + * ``` + * [{ + * id: 'light', + * title: 'Light Theme', + * variant: 'light', + * theme: lightTheme, + * }, { + * id: 'dark', + * title: 'Dark Theme', + * variant: 'dark', + * theme: darkTheme, + * }] + * ``` */ - themes?: - | { - light?: BackstageTheme; - dark?: BackstageTheme; - } - | AppTheme[]; + themes?: AppTheme[]; }; export type BackstageApp = { From 77d989f3835d79889f800874b7cf472ebb547af1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 09:28:37 +0200 Subject: [PATCH 09/14] packages/core: fix for AppThemeProvider and use it in App Provider --- packages/core/src/api/app/App.tsx | 30 ++++++++++++++----- .../core/src/api/app/AppThemeProvider.tsx | 6 ++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/core/src/api/app/App.tsx b/packages/core/src/api/app/App.tsx index 50eaac6a2c..128f978139 100644 --- a/packages/core/src/api/app/App.tsx +++ b/packages/core/src/api/app/App.tsx @@ -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,9 +30,17 @@ import { SystemIconKey, defaultSystemIcons, } from '../../icons'; -import { ApiHolder, ApiProvider, ApiRegistry, AppTheme } from '../apis'; +import { + ApiHolder, + ApiProvider, + ApiRegistry, + AppTheme, + AppThemeSelector, + appThemeApiRef, +} from '../apis'; import LoginPage from './LoginPage'; -import { lightTheme } from '@backstage/theme'; +import { lightTheme, darkTheme } from '@backstage/theme'; +import { ApiAggregator } from '../apis/ApiAggregator'; type FullAppOptions = { apis: ApiHolder; @@ -133,23 +142,28 @@ class AppImpl implements BackstageApp { , ); - let rendered = ( + const rendered = ( {routes} ); - if (this.apis) { - 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 }) => ( - {children} + + + {children} + + ); return Provider; } diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx index 07de903dd5..cd58c70d9c 100644 --- a/packages/core/src/api/app/AppThemeProvider.tsx +++ b/packages/core/src/api/app/AppThemeProvider.tsx @@ -80,17 +80,17 @@ export const AppThemeProvider: FC<{}> = ({ children }) => { appThemeApi.getActiveThemeId(), ); - const theme = resolveTheme( + const appTheme = resolveTheme( themeId, preferDark, appThemeApi.getInstalledThemes(), ); - if (!theme) { + if (!appTheme) { throw new Error('App has no themes'); } return ( - + {children} ); From 30c082bc40ae815c93349d696749bf6fac4c74b2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 09:29:03 +0200 Subject: [PATCH 10/14] packages/app: switch to using theme config --- packages/app/src/App.tsx | 58 +++------------- packages/app/src/ThemeContext.tsx | 69 ------------------- .../Root/ToggleThemeSidebarItem.tsx | 67 +++++++++--------- 3 files changed, 46 insertions(+), 148 deletions(-) delete mode 100644 packages/app/src/ThemeContext.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index dd4bf42c2c..fc2e876468 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 ( - - - - - - - - - - - - - - - ); -}; +const App: FC<{}> = () => ( + + + + + + + + +); export default App; diff --git a/packages/app/src/ThemeContext.tsx b/packages/app/src/ThemeContext.tsx deleted file mode 100644 index 3d5a04f22a..0000000000 --- a/packages/app/src/ThemeContext.tsx +++ /dev/null @@ -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({ - 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]; -} diff --git a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx index 6c0cd09244..a17e8f7019 100644 --- a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx +++ b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx @@ -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 => { - 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 ( - - ); - }} - +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 ; }; export default ToggleThemeSidebarItem; From 6b5ac0a8e102adf90bfc4154e07276002d4e18ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 09:37:04 +0200 Subject: [PATCH 11/14] packages/dev-utils: remove redundant theme wrapping --- packages/dev-utils/src/devApp/render.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 3a69faa18f..90a0847b4d 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -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 ( - - - - - {sidebar} - - - - - + + + {sidebar} + + + ); }; From a386750b7da8ea7e58106593ecbe847613f08872 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 10:03:33 +0200 Subject: [PATCH 12/14] packages/cli: remove reduntant theme provider from app template --- .../templates/default-app/packages/app/src/App.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index e8b82ceecd..c32efd2a6b 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -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 ( - - - - - - - + + + ); }; From f3abf95f7f33d9f62f32037bd14f55a25f83f456 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 14:13:24 +0200 Subject: [PATCH 13/14] packages/core: fix localStorage check in AppThemeSelector --- .../AppThemeSelector/AppThemeSelector.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts index 59bc746c41..cc1146b336 100644 --- a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts +++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts @@ -23,16 +23,20 @@ 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; + window.localStorage.getItem(STORAGE_KEY) ?? undefined; selector.setActiveThemeId(initialThemeId); selector.activeThemeId$().subscribe((themeId) => { if (themeId) { - window?.localStorage.setItem(STORAGE_KEY, themeId); + window.localStorage.setItem(STORAGE_KEY, themeId); } else { - window?.localStorage.removeItem(STORAGE_KEY); + window.localStorage.removeItem(STORAGE_KEY); } }); From 0563fde3b6f8380da922f7d795e9675e7e20ce8b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 15 May 2020 14:14:07 +0200 Subject: [PATCH 14/14] packages/core: clearer naming in AppThemeProvider --- packages/core/src/api/app/AppThemeProvider.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx index cd58c70d9c..6e68fe9ba0 100644 --- a/packages/core/src/api/app/AppThemeProvider.tsx +++ b/packages/core/src/api/app/AppThemeProvider.tsx @@ -23,7 +23,7 @@ import { useObservable } from 'react-use'; // accurate results in order to avoid errors. function resolveTheme( themeId: string | undefined, - preferDark: boolean, + shouldPreferDark: boolean, themes: AppTheme[], ) { if (themeId !== undefined) { @@ -33,7 +33,7 @@ function resolveTheme( } } - if (preferDark) { + if (shouldPreferDark) { const darkTheme = themes.find((theme) => theme.variant === 'dark'); if (darkTheme) { return darkTheme; @@ -48,7 +48,7 @@ function resolveTheme( return themes[0]; } -const usePrefersDarkTheme = () => { +const useShouldPreferDarkTheme = () => { if (!window.matchMedia) { return false; } @@ -57,7 +57,7 @@ const usePrefersDarkTheme = () => { () => window.matchMedia('(prefers-color-scheme: dark)'), [], ); - const [preferDark, setPrefersDark] = useState(mediaQuery.matches); + const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches); useEffect(() => { const listener = (event: MediaQueryListEvent) => { @@ -67,14 +67,14 @@ const usePrefersDarkTheme = () => { return () => { mediaQuery.removeListener(listener); }; - }, []); + }, [mediaQuery]); - return preferDark; + return shouldPreferDark; }; export const AppThemeProvider: FC<{}> = ({ children }) => { const appThemeApi = useApi(appThemeApiRef); - const preferDark = usePrefersDarkTheme(); + const shouldPreferDark = useShouldPreferDarkTheme(); const themeId = useObservable( appThemeApi.activeThemeId$(), appThemeApi.getActiveThemeId(), @@ -82,7 +82,7 @@ export const AppThemeProvider: FC<{}> = ({ children }) => { const appTheme = resolveTheme( themeId, - preferDark, + shouldPreferDark, appThemeApi.getInstalledThemes(), ); if (!appTheme) {