Merge pull request #6749 from backstage/rugvip/basepath

core-app-api: add support for serving the app on a base path other than /
This commit is contained in:
Fredrik Adelöw
2021-08-09 13:43:58 +02:00
committed by GitHub
16 changed files with 159 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': patch
---
Add support for serving the app with a base path other than `/`, which is enabled by including the path in `app.baseUrl`.
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/create-app': patch
---
Updated the index page redirect to work with apps served on a different base path than `/`.
To apply this change to an existing app, remove the `/` prefix from the target route in the `Navigate` element in `packages/app/src/App.tsx`:
```diff
-<Navigate key="/" to="/catalog" />
+<Navigate key="/" to="catalog" />
```
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/create-app': patch
---
Removed the `/` prefix in the catalog `SidebarItem` element, as it is no longer needed.
To apply this change to an existing app, remove the `/` prefix from the catalog and any other sidebar items in `packages/app/src/components/Root/Root.ts`:
```diff
-<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
+<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fix for `SidebarItem` matching the active route too broadly.
@@ -187,7 +187,7 @@ new `CustomCatalogIndexPage`.
# packages/app/src/App.tsx
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Navigate key="/" to="catalog" />
- <Route path="/catalog" element={<CatalogIndexPage />} />
+ <Route path="/catalog" element={<CustomCatalogIndexPage />} />
```
+1 -1
View File
@@ -98,7 +98,7 @@ const AppRouter = app.getRouter();
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Navigate key="/" to="catalog" />
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
+1 -1
View File
@@ -82,7 +82,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarSearch />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
+18 -10
View File
@@ -107,6 +107,20 @@ export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) {
return result;
}
/**
* Get the app base path from the configured app baseUrl.
*
* The returned path does not have a trailing slash.
*/
function getBasePath(configApi: Config) {
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
pathname = pathname.replace(/\/*$/, '');
return pathname;
}
type FullAppOptions = {
apis: Iterable<AnyApiFactory>;
icons: NonNullable<AppOptions['icons']>;
@@ -302,6 +316,7 @@ export class PrivateAppImpl implements BackstageApp {
routeParents={routeParents}
routeObjects={routeObjects}
routeBindings={generateBoundRoutes(this.bindRoutes)}
basePath={getBasePath(loadedConfig.api)}
>
{children}
</RoutingProvider>
@@ -339,14 +354,7 @@ export class PrivateAppImpl implements BackstageApp {
const AppRouter = ({ children }: PropsWithChildren<{}>) => {
const configApi = useApi(configApiRef);
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://dummy.dev', // baseUrl can be specified as just a path
);
if (pathname.endsWith('/')) {
pathname = pathname.replace(/\/$/, '');
}
const mountPath = `${getBasePath(configApi)}/*`;
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
@@ -361,7 +369,7 @@ export class PrivateAppImpl implements BackstageApp {
return (
<RouterComponent>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
<Route path={mountPath} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
@@ -371,7 +379,7 @@ export class PrivateAppImpl implements BackstageApp {
<RouterComponent>
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
<Route path={mountPath} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
@@ -65,7 +65,7 @@ const externalRef4 = createExternalRouteRef({
describe('RouteResolver', () => {
it('should not resolve anything with an empty resolver', () => {
const r = new RouteResolver(new Map(), new Map(), [], new Map());
const r = new RouteResolver(new Map(), new Map(), [], new Map(), '');
expect(r.resolve(ref1, '/')?.()).toBe(undefined);
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
@@ -85,6 +85,7 @@ describe('RouteResolver', () => {
new Map(),
[{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }],
new Map(),
'',
);
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
@@ -99,6 +100,29 @@ describe('RouteResolver', () => {
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
});
it('should resolve an absolute route and an app base path', () => {
const r = new RouteResolver(
new Map([[ref1, '/my-route']]),
new Map(),
[{ routeRefs: new Set([ref1]), path: '/my-route', ...rest }],
new Map(),
'/base',
);
expect(r.resolve(ref1, '/')?.()).toBe('/base/my-route');
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
expect(r.resolve(subRef1, '/')?.()).toBe('/base/my-route/foo');
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(
'/base/my-route/foo/2a',
);
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined);
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined);
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
});
it('should resolve an absolute route with a param and with a parent', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
@@ -122,6 +146,7 @@ describe('RouteResolver', () => {
[externalRef3, ref2],
[externalRef4, subRef3],
]),
'',
);
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
@@ -179,6 +204,7 @@ describe('RouteResolver', () => {
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
);
expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x');
@@ -232,6 +258,7 @@ describe('RouteResolver', () => {
[externalRef3, ref2],
[externalRef4, subRef3],
]),
'',
);
const l = '/my-grandparent/my-y/my-parent/my-x';
@@ -187,6 +187,7 @@ export class RouteResolver {
ExternalRouteRef,
RouteRef | SubRouteRef
>,
private readonly appBasePath: string, // base path without a trailing slash
) {}
resolve<Params extends AnyParams>(
@@ -209,13 +210,15 @@ export class RouteResolver {
// Next we figure out the base path, which is the combination of the common parent path
// between our current location and our target location, as well as the additional path
// that is the difference between the parent path and the base of our target location.
const basePath = resolveBasePath(
targetRef,
sourceLocation,
this.routePaths,
this.routeParents,
this.routeObjects,
);
const basePath =
this.appBasePath +
resolveBasePath(
targetRef,
sourceLocation,
this.routePaths,
this.routeParents,
this.routeObjects,
);
const routeFunc: RouteFunc<Params> = (...[params]) => {
return basePath + generatePath(targetPath, params);
@@ -152,6 +152,7 @@ function withRoutingProvider(
routeParents={routeParents}
routeObjects={routeObjects}
routeBindings={new Map(routeBindings)}
basePath=""
>
{root}
</RoutingProvider>
@@ -367,6 +368,7 @@ describe('v1 consumer', () => {
routeParents={new Map()}
routeObjects={[]}
routeBindings={new Map()}
basePath="/base"
children={children}
/>
),
@@ -375,8 +377,8 @@ describe('v1 consumer', () => {
expect(renderedHook.result.current).toBe(undefined);
renderedHook.rerender({ routeRef: routeRef2 });
expect(renderedHook.result.current?.()).toBe('/foo');
expect(renderedHook.result.current?.()).toBe('/base/foo');
renderedHook.rerender({ routeRef: routeRef3 });
expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x');
expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/base/bar/my-x');
});
});
@@ -38,6 +38,7 @@ type ProviderProps = {
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
basePath?: string;
children: ReactNode;
};
@@ -46,6 +47,7 @@ export const RoutingProvider = ({
routeParents,
routeObjects,
routeBindings,
basePath = '',
children,
}: ProviderProps) => {
const resolver = new RouteResolver(
@@ -53,6 +55,7 @@ export const RoutingProvider = ({
routeParents,
routeObjects,
routeBindings,
basePath,
);
const versionedValue = createVersionedValueMap({ 1: resolver });
@@ -34,7 +34,12 @@ import React, {
useContext,
useState,
} from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import {
Link,
NavLinkProps,
useLocation,
useResolvedPath,
} from 'react-router-dom';
import { sidebarConfig, SidebarContext } from './config';
const useStyles = makeStyles<BackstageTheme>(theme => {
@@ -147,6 +152,54 @@ function isButtonItem(
return (props as SidebarItemLinkProps).to === undefined;
}
// TODO(Rugvip): Remove this once NavLink is updated in react-router-dom.
// This is needed because react-router doesn't handle the path comparison
// properly yet, matching for example /foobar with /foo.
export const WorkaroundNavLink = React.forwardRef<
HTMLAnchorElement,
NavLinkProps
>(function WorkaroundNavLinkWithRef(
{
to,
end,
style,
className,
activeStyle,
caseSensitive,
activeClassName = 'active',
'aria-current': ariaCurrentProp = 'page',
...rest
},
ref,
) {
let { pathname: locationPathname } = useLocation();
let { pathname: toPathname } = useResolvedPath(to);
if (!caseSensitive) {
locationPathname = locationPathname.toLowerCase();
toPathname = toPathname.toLowerCase();
}
let isActive = locationPathname === toPathname;
if (!isActive && !end) {
// This is the behavior that is different from the original NavLink
isActive = locationPathname.startsWith(`${toPathname}/`);
}
const ariaCurrent = isActive ? ariaCurrentProp : undefined;
return (
<Link
{...rest}
to={to}
ref={ref}
aria-current={ariaCurrent}
style={{ ...style, ...(isActive ? activeStyle : undefined) }}
className={clsx([className, isActive ? activeClassName : undefined])}
/>
);
});
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
const {
icon: Icon,
@@ -211,7 +264,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
}
return (
<NavLink
<WorkaroundNavLink
{...childProps}
activeClassName={classes.selected}
to={props.to}
@@ -220,7 +273,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
{...navLinkProps}
>
{content}
</NavLink>
</WorkaroundNavLink>
);
});
@@ -42,7 +42,7 @@ const AppRouter = app.getRouter();
const routes = (
<FlatRoutes>
<Navigate key="/" to="/catalog" />
<Navigate key="/" to="catalog" />
<Route path="/catalog" element={<CatalogIndexPage />} />
<Route
path="/catalog/:namespace/:kind/:name"
@@ -77,7 +77,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarSearch />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
+1 -1
View File
@@ -80,7 +80,7 @@ sidebar:
export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
+ <SidebarItem icon={HomeIcon} to="/catalog" text="Home" />
+ <SidebarItem icon={HomeIcon} to="catalog" text="Home" />
...
</Sidebar>
```