plugin-app: substitute path params in app/routes redirect targets
The app/routes redirect config now performs the same :param and * substitution that the legacy Redirect component did before navigating. Named params captured by the `from` pattern are replaced in the `to` string, enabling redirects like /users/:userId → /profile/:userId and /old-docs → /docs/* (with splat forwarding). Adds tests for both named-param and splat substitution. Signed-off-by: Patrik Oldsberg <rugvip@backstage.io> Signed-off-by: Fredrik Adelöw <freben@spotify.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,102 @@ 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 interfere with normal routes when redirects are configured', async () => {
|
||||
const homePage = PageBlueprint.make({
|
||||
name: 'home',
|
||||
|
||||
@@ -21,7 +21,16 @@ 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)) {
|
||||
target = target.replaceAll(name === '*' ? '*' : `:${name}`, value ?? '');
|
||||
}
|
||||
return <Navigate to={target} replace />;
|
||||
}
|
||||
|
||||
export const AppRoutes = createExtension({
|
||||
name: 'routes',
|
||||
@@ -54,7 +63,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);
|
||||
|
||||
Reference in New Issue
Block a user