diff --git a/.changeset/fuzzy-rivers-search.md b/.changeset/fuzzy-rivers-search.md new file mode 100644 index 0000000000..06e2dda6d7 --- /dev/null +++ b/.changeset/fuzzy-rivers-search.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': minor +--- + +Added a new `AppRouter` component that replaces the same component currently created through `app.getRouter()`. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 70312a54a5..137d6d72ca 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -227,6 +227,9 @@ export type AppRouteBinder = < >, ) => void; +// @public +export function AppRouter({ children }: { children?: ReactNode }): JSX.Element; + // @public export class AppThemeSelector implements AppThemeApi { constructor(themes: AppTheme[]); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index f4fa2d8442..a5c981a54e 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -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, @@ -413,7 +372,10 @@ export class AppManager implements BackstageApp { basePath={getBasePath(loadedConfig.api)} > {children} @@ -427,104 +389,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; - children: ReactElement; - }) => { - const [identityApi, setIdentityApi] = useState(); - const configApi = useApi(configApiRef); - const basePath = getBasePath(configApi); - - if (!identityApi) { - return ; - } - - 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 ( - - - - {children}} /> - - - ); - } - - return ( - - - {children} - - ); - } - - if (isReactRouterBeta()) { - return ( - - - - - {children}} /> - - - - ); - } - - return ( - - - - <>{children} - - - ); - }; - return AppRouter; } diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx new file mode 100644 index 0000000000..c95bdaa7c5 --- /dev/null +++ b/packages/core-app-api/src/app/AppRouter.tsx @@ -0,0 +1,189 @@ +/* + * 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; + appIdentityProxy: AppIdentityProxy; + children: ReactNode; +}) { + const [identityApi, setIdentityApi] = useState(); + const configApi = useApi(configApiRef); + const basePath = getBasePath(configApi); + + if (!identityApi) { + return ; + } + + 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({ children }: { children?: ReactNode }) { + 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 ( + + + + {children}} /> + + + ); + } + + return ( + + + {children} + + ); + } + + if (isReactRouterBeta()) { + return ( + + + + + {children}} /> + + + + ); + } + + return ( + + + + {children} + + + ); +} diff --git a/packages/core-app-api/src/app/InternalAppContext.ts b/packages/core-app-api/src/app/InternalAppContext.ts new file mode 100644 index 0000000000..81382acf62 --- /dev/null +++ b/packages/core-app-api/src/app/InternalAppContext.ts @@ -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); diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts index 7843b36339..f6289a5830 100644 --- a/packages/core-app-api/src/app/index.ts +++ b/packages/core-app-api/src/app/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { AppRouter } from './AppRouter'; export { createSpecializedApp } from './createSpecializedApp'; export { defaultConfigLoader } from './defaultConfigLoader'; export * from './types'; diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts index 70022534a1..861766f8d3 100644 --- a/packages/core-app-api/src/app/types.ts +++ b/packages/core-app-api/src/app/types.ts @@ -307,6 +307,8 @@ export type BackstageApp = { /** * 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<{}>; };