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';