diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c2273156ac..d3ab0649e9 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -31,8 +31,7 @@ import { AlertDisplay, OAuthRequestDialog, SignInPage, - defaultAppIcons, - defaultAppComponents, + withDefaults, } from '@backstage/core-components'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { @@ -88,47 +87,46 @@ import * as plugins from './plugins'; import { techDocsPage } from './components/techdocs/TechDocsPage'; -const app = createApp({ - apis, - plugins: Object.values(plugins), - icons: { - ...defaultAppIcons(), - // Custom icon example - alert: AlarmIcon, - }, - - components: { - ...defaultAppComponents(), - SignInPage: props => { - return ( - - ); +const app = createApp( + withDefaults({ + apis, + plugins: Object.values(plugins), + icons: { + // Custom icon example + alert: AlarmIcon, }, - }, - bindRoutes({ bind }) { - bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.root, - viewTechDoc: techdocsPlugin.routes.docRoot, - }); - bind(catalogGraphPlugin.externalRoutes, { - catalogEntity: catalogPlugin.routes.catalogEntity, - }); - bind(apiDocsPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.root, - }); - bind(explorePlugin.externalRoutes, { - catalogEntity: catalogPlugin.routes.catalogEntity, - }); - bind(scaffolderPlugin.externalRoutes, { - registerComponent: catalogImportPlugin.routes.importPage, - }); - }, -}); + components: { + SignInPage: props => { + return ( + + ); + }, + }, + bindRoutes({ bind }) { + bind(catalogPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + viewTechDoc: techdocsPlugin.routes.docRoot, + }); + bind(catalogGraphPlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); + bind(apiDocsPlugin.externalRoutes, { + createComponent: scaffolderPlugin.routes.root, + }); + bind(explorePlugin.externalRoutes, { + catalogEntity: catalogPlugin.routes.catalogEntity, + }); + bind(scaffolderPlugin.externalRoutes, { + registerComponent: catalogImportPlugin.routes.importPage, + }); + }, + }), +); const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); diff --git a/packages/core-app-api/src/app/createApp.tsx b/packages/core-app-api/src/app/createApp.tsx index f5989219e8..c1e9741ff1 100644 --- a/packages/core-app-api/src/app/createApp.tsx +++ b/packages/core-app-api/src/app/createApp.tsx @@ -16,20 +16,15 @@ import { AppConfig } from '@backstage/config'; import { JsonObject } from '@backstage/types'; -import { - defaultAppComponents, - defaultAppIcons, - defaultAppThemes, -} from '@backstage/core-components'; -import { BrowserRouter } from 'react-router-dom'; +import { withDefaults } from '@backstage/core-components'; import { PrivateAppImpl } from './App'; -import { AppThemeProvider } from './AppThemeProvider'; +import { AppComponents, AppConfigLoader, AppOptions } from './types'; import { defaultApis } from './defaultApis'; -import { AppConfigLoader, AppOptions } from './types'; -import { AppComponents, BackstagePlugin } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; const REQUIRED_APP_COMPONENTS: Array = [ 'Progress', + 'Router', 'NotFoundErrorPage', 'BootErrorPage', 'ErrorBoundaryFallback', @@ -95,6 +90,8 @@ export const defaultConfigLoader: AppConfigLoader = async ( * @public */ export function createApp(options?: AppOptions) { + const optionsWithDefaults = withDefaults(options); + const missingRequiredComponents = REQUIRED_APP_COMPONENTS.filter( name => !options?.components?.[name], ); @@ -111,9 +108,8 @@ export function createApp(options?: AppOptions) { ); } - const appIcons = defaultAppIcons(); const providedIconKeys = Object.keys(options?.icons ?? {}); - const missingIconKeys = Object.keys(appIcons).filter( + const missingIconKeys = Object.keys(optionsWithDefaults.icons!).filter( key => !providedIconKeys.includes(key), ); if (missingIconKeys.length > 0) { @@ -135,24 +131,16 @@ export function createApp(options?: AppOptions) { ); } - const apis = options?.apis ?? []; - const plugins = options?.plugins ?? []; - const components = { - ...defaultAppComponents(), - Router: BrowserRouter, - ThemeProvider: AppThemeProvider, - ...options?.components, - }; - const configLoader = options?.configLoader ?? defaultConfigLoader; + const { icons, themes, components } = optionsWithDefaults; return new PrivateAppImpl({ - apis, - icons: { ...appIcons, ...options?.icons }, - plugins: plugins as BackstagePlugin[], - components, - themes: options?.themes ?? defaultAppThemes(), - configLoader, + icons: icons!, + themes: themes!, + components: components! as AppComponents, defaultApis, + apis: options?.apis ?? [], bindRoutes: options?.bindRoutes, + plugins: (options?.plugins as BackstagePlugin[]) ?? [], + configLoader: options?.configLoader ?? defaultConfigLoader, }); } diff --git a/packages/core-components/src/appDefaults/AppThemeProvider.tsx b/packages/core-components/src/appDefaults/AppThemeProvider.tsx new file mode 100644 index 0000000000..aa48a2187d --- /dev/null +++ b/packages/core-components/src/appDefaults/AppThemeProvider.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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, { useMemo, useEffect, useState, PropsWithChildren } from 'react'; +import { ThemeProvider } from '@material-ui/core/styles'; +import CssBaseline from '@material-ui/core/CssBaseline'; +import { useApi, appThemeApiRef, AppTheme } from '@backstage/core-plugin-api'; +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 = () => { + 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 function AppThemeProvider({ children }: PropsWithChildren<{}>) { + const appThemeApi = useApi(appThemeApiRef); + const themeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + + // Browser feature detection won't change over time, so ignore lint rule + const shouldPreferDark = Boolean(window.matchMedia) + ? useShouldPreferDarkTheme() // eslint-disable-line react-hooks/rules-of-hooks + : false; + + const appTheme = resolveTheme( + themeId, + shouldPreferDark, + appThemeApi.getInstalledThemes(), + ); + if (!appTheme) { + throw new Error('App has no themes'); + } + + if (appTheme.Provider) { + return ; + } + + // eslint-disable-next-line no-console + console.warn( + "DEPRECATION WARNING: A provided app theme is using the deprecated 'theme' property " + + 'and should be migrated to use a Provider instead. ' + + 'See https://backstage.io/docs/deprecations/TODO for more info.', + ); + + return ( + + {children} + + ); +} diff --git a/packages/core-components/src/defaultAppComponents.test.tsx b/packages/core-components/src/appDefaults/defaultAppComponents.test.tsx similarity index 100% rename from packages/core-components/src/defaultAppComponents.test.tsx rename to packages/core-components/src/appDefaults/defaultAppComponents.test.tsx diff --git a/packages/core-components/src/defaultAppComponents.tsx b/packages/core-components/src/appDefaults/defaultAppComponents.tsx similarity index 88% rename from packages/core-components/src/defaultAppComponents.tsx rename to packages/core-components/src/appDefaults/defaultAppComponents.tsx index fb810a6272..ab55a25840 100644 --- a/packages/core-components/src/defaultAppComponents.tsx +++ b/packages/core-components/src/appDefaults/defaultAppComponents.tsx @@ -16,14 +16,16 @@ import React, { ReactNode } from 'react'; import Button from '@material-ui/core/Button'; +import { ErrorPanel, Progress } from '../components'; +import { ErrorPage } from '../layout'; +import { MemoryRouter, useInRouterContext } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; import { AppComponents, BootErrorPageProps, ErrorBoundaryFallbackProps, } from '@backstage/core-plugin-api'; -import { ErrorPanel, Progress } from './components'; -import { ErrorPage } from './layout'; -import { MemoryRouter, useInRouterContext } from 'react-router'; +import { AppThemeProvider } from './AppThemeProvider'; export function OptionallyWrapInRouter({ children }: { children: ReactNode }) { if (useInRouterContext()) { @@ -73,9 +75,11 @@ const DefaultErrorBoundaryFallback = ({ * * @public */ -export function defaultAppComponents(): Omit { +export function defaultAppComponents(): AppComponents { return { Progress, + Router: BrowserRouter, + ThemeProvider: AppThemeProvider, NotFoundErrorPage: DefaultNotFoundPage, BootErrorPage: DefaultBootErrorPage, ErrorBoundaryFallback: DefaultErrorBoundaryFallback, diff --git a/packages/core-components/src/defaultAppIcons.tsx b/packages/core-components/src/appDefaults/defaultAppIcons.tsx similarity index 100% rename from packages/core-components/src/defaultAppIcons.tsx rename to packages/core-components/src/appDefaults/defaultAppIcons.tsx diff --git a/packages/core-components/src/defaultAppThemes.tsx b/packages/core-components/src/appDefaults/defaultAppThemes.tsx similarity index 100% rename from packages/core-components/src/defaultAppThemes.tsx rename to packages/core-components/src/appDefaults/defaultAppThemes.tsx diff --git a/packages/core-components/src/appDefaults/index.ts b/packages/core-components/src/appDefaults/index.ts new file mode 100644 index 0000000000..287ad3cac6 --- /dev/null +++ b/packages/core-components/src/appDefaults/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { withDefaults } from './withDefaults'; diff --git a/packages/core-components/src/appDefaults/withDefaults.tsx b/packages/core-components/src/appDefaults/withDefaults.tsx new file mode 100644 index 0000000000..51adaf22ef --- /dev/null +++ b/packages/core-components/src/appDefaults/withDefaults.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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, IconComponent } from '@backstage/core-plugin-api'; +// This is a bit of a hack that we use to avoid having to redeclare these types +// within this package or have an explicit dependency on core-app-api. +// These types end up being inlined and duplicated into this package at build time. +// eslint-disable-next-line no-restricted-imports +import { + AppIcons, + AppComponents, + AppOptions, +} from '../../../core-app-api/src/app'; +import { defaultAppComponents } from './defaultAppComponents'; +import { defaultAppIcons } from './defaultAppIcons'; +import { defaultAppThemes } from './defaultAppThemes'; + +interface OptionalAppOptions { + icons?: Partial & { + [key in string]: IconComponent; + }; + themes?: (Partial & Omit)[]; + components?: Partial; +} + +/** + * The options required by {@link @backstage/core-app-api#createApp}, but with + * many of the fields being optional + * + * @public + */ +type DefaultAppOptions = Omit & + OptionalAppOptions; + +/** + * Provides a set of default App options with the ability to override specific options. + * + * These options populate the theme, icons and components options of {@link @backstage/core-app-api#AppOptions}. + * + * @public + */ +export function withDefaults(options?: DefaultAppOptions): AppOptions { + const { themes, icons, components } = options ?? {}; + + return { + ...options, + themes: themes ?? defaultAppThemes(), + icons: { ...defaultAppIcons(), ...icons }, + components: { ...defaultAppComponents(), ...components }, + }; +} diff --git a/packages/core-components/src/index.ts b/packages/core-components/src/index.ts index 305783dec8..ea1fea9d5c 100644 --- a/packages/core-components/src/index.ts +++ b/packages/core-components/src/index.ts @@ -25,6 +25,4 @@ export * from './hooks'; export * from './icons'; export * from './layout'; export * from './overridableComponents'; -export { defaultAppComponents } from './defaultAppComponents'; -export { defaultAppIcons } from './defaultAppIcons'; -export { defaultAppThemes } from './defaultAppThemes'; +export * from './appDefaults'; diff --git a/packages/core-plugin-api/src/app/types.ts b/packages/core-plugin-api/src/app/types.ts index d072f0a2e6..df78728a21 100644 --- a/packages/core-plugin-api/src/app/types.ts +++ b/packages/core-plugin-api/src/app/types.ts @@ -14,110 +14,15 @@ * limitations under the License. */ -import { ComponentType } from 'react'; -import { ProfileInfo } from '../apis/definitions'; -import { IconComponent } from '../icons'; -import { BackstagePlugin } from '../plugin/types'; - -/** - * Props for the BootErrorPage. - * - * @public - */ -export type BootErrorPageProps = { - step: 'load-config' | 'load-chunk'; - error: Error; -}; - -/** - * Data and handlers associated with the user sign in event. - * - * @public - */ -export type SignInResult = { - /** - * User ID that will be returned by the IdentityApi - */ - userId: string; - - profile: ProfileInfo; - - /** - * Function used to retrieve an ID token for the signed in user. - */ - getIdToken?: () => Promise; - - /** - * Sign out handler that will be called if the user requests to sign out. - */ - signOut?: () => Promise; -}; - -/** - * Props for the SignInPage. - * - * @public - */ -export type SignInPageProps = { - /** - * Set the sign-in result for the app. This should only be called once. - */ - onResult(result: SignInResult): void; -}; - -/** - * Props for the ErrorBoundaryFallback. - * - * @public - */ -export type ErrorBoundaryFallbackProps = { - plugin?: BackstagePlugin; - error: Error; - resetError: () => void; -}; - -/** - * Basic app components. - * - * @public - */ -export type AppComponents = { - NotFoundErrorPage: ComponentType<{}>; - BootErrorPage: ComponentType; - Progress: ComponentType<{}>; - Router: ComponentType<{}>; - ErrorBoundaryFallback: ComponentType; - - /** - * An optional sign-in page that will be rendered instead of the AppRouter at startup. - * - * If a sign-in page is set, it will always be shown before the app, and it is up - * to the sign-in page to handle e.g. saving of login methods for subsequent visits. - * - * The sign-in page will be displayed until it has passed up a result to the parent, - * and which point the AppRouter and all of its children will be rendered instead. - */ - SignInPage?: ComponentType; -}; - -/** - * Provides plugins and components registered in the app. - * - * @public - */ -export type AppContext = { - /** - * Get a list of all plugins that are installed in the app. - */ - getPlugins(): BackstagePlugin[]; - - /** - * Get a common or custom icon for this app. - */ - getSystemIcon(key: string): IconComponent | undefined; - - /** - * Get the components registered for various purposes in the app. - */ - getComponents(): AppComponents; -}; +// This is a bit of a hack that we use to avoid having to redeclare these types +// within this package or have an explicit dependency on core-app-api. +// These types end up being inlined and duplicated into this package at build time. +// eslint-disable-next-line no-restricted-imports +export type { + BootErrorPageProps, + SignInResult, + SignInPageProps, + ErrorBoundaryFallbackProps, + AppComponents, + AppContext, +} from '../../../core-app-api/src/app/types';