Merge pull request #34150 from backstage/rugvip/app-routes-param-redirects

plugin-app: substitute path params in app/routes redirect targets
This commit is contained in:
Fredrik Adelöw
2026-05-07 15:33:19 +03:00
committed by GitHub
3 changed files with 176 additions and 2 deletions
@@ -0,0 +1,17 @@
---
'@backstage/plugin-app': patch
---
The `app/routes` redirect config now supports path parameter substitution in the `to` target. Named params (`:userId`) and splat params (`*`) captured by the `from` path are replaced in the `to` string before navigating, making it possible to express redirects like:
```yaml
app:
extensions:
- app/routes:
config:
redirects:
- from: /users/:userId
to: /profile/:userId
- from: /old-docs
to: /docs/*
```
@@ -443,6 +443,149 @@ describe('AppRoutes', () => {
});
});
it('should substitute named path params in redirect target', async () => {
const LocationDisplay = () => {
const location = useLocation();
return <div data-testid="location">{location.pathname}</div>;
};
const profilePage = PageBlueprint.make({
name: 'profile',
params: {
path: '/profile/:userId',
loader: async () => (
<div>
Profile Page
<LocationDisplay />
</div>
),
},
});
renderTestApp({
extensions: [profilePage],
initialRouteEntries: ['/users/alice'],
config: {
...DEFAULT_CONFIG,
app: {
...DEFAULT_CONFIG.app,
extensions: [
{
'app/routes': {
config: {
redirects: [
{ from: '/users/:userId', to: '/profile/:userId' },
],
},
},
},
],
},
},
});
await waitFor(() => {
expect(screen.getByText('Profile Page')).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent(
'/profile/alice',
);
});
});
it('should substitute splat param in redirect target', async () => {
const LocationDisplay = () => {
const location = useLocation();
return <div data-testid="location">{location.pathname}</div>;
};
const docsPage = PageBlueprint.make({
name: 'docs',
params: {
path: '/docs',
loader: async () => (
<div>
Docs Page
<LocationDisplay />
</div>
),
},
});
renderTestApp({
extensions: [docsPage],
initialRouteEntries: ['/d/default/component/my-entity'],
config: {
...DEFAULT_CONFIG,
app: {
...DEFAULT_CONFIG.app,
extensions: [
{
'app/routes': {
config: {
redirects: [{ from: '/d', to: '/docs/*' }],
},
},
},
],
},
},
});
await waitFor(() => {
expect(screen.getByText('Docs Page')).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent(
'/docs/default/component/my-entity',
);
});
});
it('should not corrupt a longer param when a shorter param is a prefix of it', async () => {
const LocationDisplay = () => {
const location = useLocation();
return <div data-testid="location">{location.pathname}</div>;
};
const targetPage = PageBlueprint.make({
name: 'target',
params: {
path: '/target/:ab/:a',
loader: async () => (
<div>
Target Page
<LocationDisplay />
</div>
),
},
});
renderTestApp({
extensions: [targetPage],
initialRouteEntries: ['/source/bar/foo'],
config: {
...DEFAULT_CONFIG,
app: {
...DEFAULT_CONFIG.app,
extensions: [
{
'app/routes': {
config: {
redirects: [{ from: '/source/:ab/:a', to: '/target/:ab/:a' }],
},
},
},
],
},
},
});
await waitFor(() => {
expect(screen.getByText('Target Page')).toBeInTheDocument();
expect(screen.getByTestId('location')).toHaveTextContent(
'/target/bar/foo',
);
});
});
it('should not interfere with normal routes when redirects are configured', async () => {
const homePage = PageBlueprint.make({
name: 'home',
+16 -2
View File
@@ -21,7 +21,21 @@ import {
createExtensionInput,
NotFoundErrorPage,
} from '@backstage/frontend-plugin-api';
import { Navigate, useRoutes } from 'react-router-dom';
import { Navigate, useParams, useRoutes } from 'react-router-dom';
function RedirectWithParams({ to }: { to: string }) {
const params = useParams() as Record<string, string>;
let target = to;
for (const [name, value] of Object.entries(params)) {
// Use \b (word boundary) for named params so that `:a` doesn't
// accidentally match inside `:ab` when both are present.
target = target.replace(
name === '*' ? /\*/g : new RegExp(`:${name}\\b`, 'g'),
value ?? '',
);
}
return <Navigate to={target} replace />;
}
export const AppRoutes = createExtension({
name: 'routes',
@@ -54,7 +68,7 @@ export const AppRoutes = createExtension({
redirect.from === '/'
? redirect.from
: `${redirect.from.replace(/\/$/, '')}/*`,
element: <Navigate to={redirect.to} replace />,
element: <RedirectWithParams to={redirect.to} />,
})),
...inputs.routes.map(route => {
const routePath = route.get(coreExtensionData.routePath);