core-components: provide withDefaults and work around type duplication

Co-authored-by: Johan Haals <johan.haals@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-10-26 19:08:50 +02:00
parent 9ddec7eaf1
commit e551a44c65
11 changed files with 265 additions and 182 deletions
+40 -42
View File
@@ -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 (
<SignInPage
{...props}
providers={['guest', 'custom', ...providers]}
title="Select a sign-in method"
align="center"
/>
);
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 (
<SignInPage
{...props}
providers={['guest', 'custom', ...providers]}
title="Select a sign-in method"
align="center"
/>
);
},
},
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();
+14 -26
View File
@@ -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<keyof AppComponents> = [
'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<any, any>[],
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<any, any>[]) ?? [],
configLoader: options?.configLoader ?? defaultConfigLoader,
});
}
@@ -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 <appTheme.Provider children={children} />;
}
// 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 (
<ThemeProvider theme={appTheme.theme}>
<CssBaseline>{children}</CssBaseline>
</ThemeProvider>
);
}
@@ -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<AppComponents, 'Router'> {
export function defaultAppComponents(): AppComponents {
return {
Progress,
Router: BrowserRouter,
ThemeProvider: AppThemeProvider,
NotFoundErrorPage: DefaultNotFoundPage,
BootErrorPage: DefaultBootErrorPage,
ErrorBoundaryFallback: DefaultErrorBoundaryFallback,
@@ -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';
@@ -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<AppIcons> & {
[key in string]: IconComponent;
};
themes?: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[];
components?: Partial<AppComponents>;
}
/**
* The options required by {@link @backstage/core-app-api#createApp}, but with
* many of the fields being optional
*
* @public
*/
type DefaultAppOptions = Omit<AppOptions, keyof OptionalAppOptions> &
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 },
};
}
+1 -3
View File
@@ -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';
+12 -107
View File
@@ -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<string>;
/**
* Sign out handler that will be called if the user requests to sign out.
*/
signOut?: () => Promise<void>;
};
/**
* 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<BootErrorPageProps>;
Progress: ComponentType<{}>;
Router: ComponentType<{}>;
ErrorBoundaryFallback: ComponentType<ErrorBoundaryFallbackProps>;
/**
* 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<SignInPageProps>;
};
/**
* 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<any, any>[];
/**
* 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';