Merge pull request #15110 from backstage/rugvip/router

core-app-api: add app.createRoot() to replace app.getProvider()
This commit is contained in:
Patrik Oldsberg
2022-12-12 11:57:05 +01:00
committed by GitHub
11 changed files with 504 additions and 160 deletions
+50
View File
@@ -0,0 +1,50 @@
---
'@backstage/core-app-api': minor
---
Added a new `AppRouter` component and `app.createRoot()` method that replaces `app.getRouter()` and `app.getProvider()`, which are now deprecated. The new `AppRouter` component is a drop-in replacement for the old router component, while the new `app.createRoot()` method is used instead of the old provider component.
An old app setup might look like this:
```tsx
const app = createApp(/* ... */);
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const routes = ...;
const App = () => (
<AppProvider>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</AppProvider>
);
export default App;
```
With these new APIs, the setup now looks like this:
```tsx
import { AppRouter } from '@backstage/core-app-api';
const app = createApp(/* ... */);
const routes = ...;
export default app.createRoot(
<>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</>,
);
```
Note that `app.createRoot()` accepts a React element, rather than a component.
+48
View File
@@ -0,0 +1,48 @@
---
'@backstage/create-app': patch
---
Updated the app template to use the new `AppRouter` component instead of `app.getRouter()`, as well as `app.createRoot()` instead of `app.getProvider()`.
To apply this change to an existing app, make the following change to `packages/app/src/App.tsx`:
```diff
-import { FlatRoutes } from '@backstage/core-app-api';
+import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
...
-const AppProvider = app.getProvider();
-const AppRouter = app.getRouter();
...
-const App = () => (
+export default app.createRoot(
- <AppProvider>
+ <>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
- </AppProvider>
+ </>,
);
```
The final export step should end up looking something like this:
```tsx
export default app.createRoot(
<>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</>,
);
```
Note that `app.createRoot()` accepts a React element, rather than a component.
+4 -9
View File
@@ -27,7 +27,7 @@ import {
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { createApp } from '@backstage/app-defaults';
import { FlatRoutes } from '@backstage/core-app-api';
import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
@@ -145,9 +145,6 @@ const app = createApp({
},
});
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const routes = (
<FlatRoutes>
<Route path="/" element={<Navigate to="catalog" />} />
@@ -278,14 +275,12 @@ const routes = (
</FlatRoutes>
);
const App = () => (
<AppProvider>
export default app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</AppProvider>
</>,
);
export default App;
+10
View File
@@ -227,6 +227,15 @@ export type AppRouteBinder = <
>,
) => void;
// @public
export function AppRouter(props: AppRouterProps): JSX.Element;
// @public
export interface AppRouterProps {
// (undocumented)
children?: ReactNode;
}
// @public
export class AppThemeSelector implements AppThemeApi {
constructor(themes: AppTheme[]);
@@ -259,6 +268,7 @@ export type AuthApiCreateOptions = {
export type BackstageApp = {
getPlugins(): BackstagePlugin[];
getSystemIcon(key: string): IconComponent | undefined;
createRoot(element: JSX.Element): ComponentType<{}>;
getProvider(): ComponentType<{}>;
getRouter(): ComponentType<{}>;
};
+22 -142
View File
@@ -17,15 +17,10 @@
import { AppConfig, Config } from '@backstage/config';
import React, {
ComponentType,
createContext,
PropsWithChildren,
ReactElement,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { Route, Routes } from 'react-router-dom';
import useAsync from 'react-use/lib/useAsync';
import {
ApiProvider,
@@ -34,7 +29,6 @@ import {
LocalStorageFeatureFlags,
} from '../apis';
import {
useApi,
AnyApiFactory,
ApiHolder,
IconComponent,
@@ -44,7 +38,6 @@ import {
AppThemeApi,
ConfigApi,
featureFlagsApiRef,
IdentityApi,
identityApiRef,
BackstagePlugin,
} from '@backstage/core-plugin-api';
@@ -61,7 +54,6 @@ import {
routingV2Collector,
} from '../routing/collectors';
import { RoutingProvider } from '../routing/RoutingProvider';
import { RouteTracker } from '../routing/RouteTracker';
import {
validateRouteParameters,
validateRouteBindings,
@@ -74,14 +66,14 @@ import {
AppContext,
AppOptions,
BackstageApp,
SignInPageProps,
} from './types';
import { AppThemeProvider } from './AppThemeProvider';
import { defaultConfigLoader } from './defaultConfigLoader';
import { ApiRegistry } from '../apis/system/ApiRegistry';
import { resolveRouteBindings } from './resolveRouteBindings';
import { BackstageRouteObject } from '../routing/types';
import { isReactRouterBeta } from './isReactRouterBeta';
import { InternalAppContext } from './InternalAppContext';
import { AppRouter, getBasePath } from './AppRouter';
type CompatiblePlugin =
| BackstagePlugin
@@ -89,39 +81,6 @@ type CompatiblePlugin =
output(): Array<{ type: 'feature-flag'; name: string }>;
});
const InternalAppContext = createContext<{
routeObjects: BackstageRouteObject[];
}>({ routeObjects: [] });
/**
* Get the app base path from the configured app baseUrl.
*
* The returned path does not have a trailing slash.
*/
function getBasePath(configApi: Config) {
if (!isReactRouterBeta()) {
// When using rr v6 stable the base path is handled through the
// basename prop on the router component instead.
return '';
}
return readBasePath(configApi);
}
/**
* Read the configured base path.
*
* The returned path does not have a trailing slash.
*/
function readBasePath(configApi: ConfigApi) {
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://sample.dev', // baseUrl can be specified as just a path
);
pathname = pathname.replace(/\/*$/, '');
return pathname;
}
function useConfigLoader(
configLoader: AppConfigLoader | undefined,
components: AppComponents,
@@ -289,7 +248,23 @@ export class AppManager implements BackstageApp {
return this.components;
}
createRoot(element: JSX.Element): ComponentType<{}> {
const AppProvider = this.getProvider();
const AppRoot = () => {
return <AppProvider>{element}</AppProvider>;
};
return AppRoot;
}
#getProviderCalled = false;
getProvider(): ComponentType<{}> {
if (this.#getProviderCalled) {
throw new Error(
'app.getProvider() or app.createRoot() has already been called, and can only be called once',
);
}
this.#getProviderCalled = true;
const appContext = new AppContextImpl(this);
// We only validate routes once
@@ -413,7 +388,10 @@ export class AppManager implements BackstageApp {
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{ routeObjects: routing.objects }}
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
>
{children}
</InternalAppContext.Provider>
@@ -427,104 +405,6 @@ export class AppManager implements BackstageApp {
}
getRouter(): ComponentType<{}> {
const { Router: RouterComponent, SignInPage: SignInPageComponent } =
this.components;
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
const SignInPageWrapper = ({
component: Component,
children,
}: {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}) => {
const [identityApi, setIdentityApi] = useState<IdentityApi>();
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
if (!identityApi) {
return <Component onSignInSuccess={setIdentityApi} />;
}
this.appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl: basePath || '/',
});
return children;
};
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
const configApi = useApi(configApiRef);
const basePath = readBasePath(configApi);
const mountPath = `${basePath}/*`;
const { routeObjects } = useContext(InternalAppContext);
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
this.appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route path={mountPath} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
{children}
</RouterComponent>
);
}
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={mountPath} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper component={SignInPageComponent}>
<>{children}</>
</SignInPageWrapper>
</RouterComponent>
);
};
return AppRouter;
}
@@ -0,0 +1,120 @@
/*
* Copyright 2022 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 from 'react';
import {
AppComponents,
configApiRef,
IdentityApi,
identityApiRef,
SignInPageProps,
useApi,
} from '@backstage/core-plugin-api';
import { InternalAppContext } from './InternalAppContext';
import { MemoryRouter } from 'react-router-dom';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
import { render, screen } from '@testing-library/react';
import { AppRouter } from './AppRouter';
import useAsync from 'react-use/lib/useAsync';
import { AppContextProvider } from './AppContext';
import { TestApiProvider } from '@backstage/test-utils';
import { ConfigReader } from '@backstage/config';
function UserRefDisplay() {
const identityApi = useApi(identityApiRef);
const { value } = useAsync(() => identityApi.getBackstageIdentity());
return <div>ref: {value?.userEntityRef}</div>;
}
describe('AppRouter', () => {
const mockComponents = {
Router: MemoryRouter,
} as AppComponents;
it('should fall back to guest if there is no sign-in page', async () => {
const appIdentityProxy = new AppIdentityProxy();
render(
<TestApiProvider
apis={[
[identityApiRef, appIdentityProxy],
[configApiRef, new ConfigReader({})],
]}
>
<InternalAppContext.Provider
value={{ routeObjects: [], appIdentityProxy }}
>
<AppContextProvider
appContext={{ getComponents: () => mockComponents } as any}
>
<AppRouter>
<UserRefDisplay />
</AppRouter>
</AppContextProvider>
</InternalAppContext.Provider>
,
</TestApiProvider>,
);
await expect(
screen.findByText('ref: user:default/guest'),
).resolves.toBeInTheDocument();
});
it('should use the result from the sign-in page', async () => {
const appIdentityProxy = new AppIdentityProxy();
const SignInPage = (props: SignInPageProps) => {
props.onSignInSuccess({
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/test',
ownershipEntityRefs: ['user:default/test'],
}),
} as IdentityApi);
return null;
};
render(
<TestApiProvider
apis={[
[identityApiRef, appIdentityProxy],
[configApiRef, new ConfigReader({})],
]}
>
<InternalAppContext.Provider
value={{ routeObjects: [], appIdentityProxy }}
>
<AppContextProvider
appContext={
{
getComponents: () => ({ ...mockComponents, SignInPage }),
} as any
}
>
<AppRouter>
<UserRefDisplay />
</AppRouter>
</AppContextProvider>
</InternalAppContext.Provider>
</TestApiProvider>,
);
await expect(
screen.findByText('ref: user:default/test'),
).resolves.toBeInTheDocument();
});
});
+188
View File
@@ -0,0 +1,188 @@
/*
* Copyright 2022 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, { useContext, ReactNode, ComponentType, useState } from 'react';
import {
ConfigApi,
configApiRef,
IdentityApi,
SignInPageProps,
useApi,
useApp,
} from '@backstage/core-plugin-api';
import { InternalAppContext } from './InternalAppContext';
import { isReactRouterBeta } from './isReactRouterBeta';
import { RouteTracker } from '../routing/RouteTracker';
import { Route, Routes } from 'react-router-dom';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
/**
* Get the app base path from the configured app baseUrl.
*
* The returned path does not have a trailing slash.
*/
export function getBasePath(configApi: ConfigApi) {
if (!isReactRouterBeta()) {
// When using rr v6 stable the base path is handled through the
// basename prop on the router component instead.
return '';
}
return readBasePath(configApi);
}
/**
* Read the configured base path.
*
* The returned path does not have a trailing slash.
*/
function readBasePath(configApi: ConfigApi) {
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://sample.dev', // baseUrl can be specified as just a path
);
pathname = pathname.replace(/\/*$/, '');
return pathname;
}
// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
function SignInPageWrapper({
component: Component,
appIdentityProxy,
children,
}: {
component: ComponentType<SignInPageProps>;
appIdentityProxy: AppIdentityProxy;
children: ReactNode;
}) {
const [identityApi, setIdentityApi] = useState<IdentityApi>();
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
if (!identityApi) {
return <Component onSignInSuccess={setIdentityApi} />;
}
appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl: basePath || '/',
});
return <>{children}</>;
}
/**
* Props for the {@link AppRouter} component.
* @public
*/
export interface AppRouterProps {
children?: ReactNode;
}
/**
* App router and sign-in page wrapper.
*
* @public
* @remarks
*
* The AppRouter provides the routing context and renders the sign-in page.
* Until the user has successfully signed in, this component will render
* the sign-in page. Once the user has signed-in, it will instead render
* the app, while providing routing and route tracking for the app.
*/
export function AppRouter(props: AppRouterProps) {
const { Router: RouterComponent, SignInPage: SignInPageComponent } =
useApp().getComponents();
const configApi = useApi(configApiRef);
const basePath = readBasePath(configApi);
const mountPath = `${basePath}/*`;
const internalAppContext = useContext(InternalAppContext);
if (!internalAppContext) {
throw new Error('AppRouter must be rendered within the AppProvider');
}
const { routeObjects, appIdentityProxy } = internalAppContext;
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
{props.children}
</RouterComponent>
);
}
if (isReactRouterBeta()) {
return (
<RouterComponent>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
<Routes>
<Route path={mountPath} element={<>{props.children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
}
return (
<RouterComponent basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{props.children}
</SignInPageWrapper>
</RouterComponent>
);
}
@@ -0,0 +1,27 @@
/*
* Copyright 2022 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 { createContext } from 'react';
import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
export const InternalAppContext = createContext<
| undefined
| {
routeObjects: BackstageRouteObject[];
appIdentityProxy: AppIdentityProxy;
}
>(undefined);
+2
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
export { AppRouter } from './AppRouter';
export type { AppRouterProps } from './AppRouter';
export { createSpecializedApp } from './createSpecializedApp';
export { defaultConfigLoader } from './defaultConfigLoader';
export * from './types';
+29
View File
@@ -298,15 +298,44 @@ export type BackstageApp = {
*/
getSystemIcon(key: string): IconComponent | undefined;
/**
* Creates the root component that renders the entire app.
*
* @remarks
*
* This method must only be called once, and you have to provide it the entire
* app element tree. The element tree will be analyzed to discover plugins,
* routes, and other app features. The returned component will render all
* of the app elements wrapped within the app context provider.
*
* @example
* ```tsx
* export default app.createRoot(
* <>
* <AlertDisplay />
* <OAuthRequestDialog />
* <AppRouter>
* <Root>{routes}</Root>
* </AppRouter>
* </>,
* );
* ```
*/
createRoot(element: JSX.Element): ComponentType<{}>;
/**
* Provider component that should wrap the Router created with getRouter()
* and any other components that need to be within the app context.
*
* @deprecated Use {@link BackstageApp.createRoot} instead.
*/
getProvider(): ComponentType<{}>;
/**
* Router component that should wrap the App Routes create with getRoutes()
* and any other components that should only be available while signed in.
*
* @deprecated Import and use the {@link AppRouter} component from `@backstage/core-app-api` instead
*/
getRouter(): ComponentType<{}>;
};
@@ -29,7 +29,7 @@ import { Root } from './components/Root';
import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
import { createApp } from '@backstage/app-defaults';
import { FlatRoutes } from '@backstage/core-app-api';
import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { CatalogGraphPage } from '@backstage/plugin-catalog-graph';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
@@ -53,9 +53,6 @@ const app = createApp({
},
});
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const routes = (
<FlatRoutes>
<Route path="/" element={<Navigate to="catalog" />} />
@@ -97,14 +94,12 @@ const routes = (
</FlatRoutes>
);
const App = () => (
<AppProvider>
export default app.createRoot(
<>
<AlertDisplay />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</AppProvider>
</>,
);
export default App;