diff --git a/.changeset/app-routes-param-redirect-substitution.md b/.changeset/app-routes-param-redirect-substitution.md new file mode 100644 index 0000000000..66fc77a4ea --- /dev/null +++ b/.changeset/app-routes-param-redirect-substitution.md @@ -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/* +``` diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index 9aa6474189..83b0c596b4 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -443,6 +443,149 @@ describe('AppRoutes', () => { }); }); + it('should substitute named path params in redirect target', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const profilePage = PageBlueprint.make({ + name: 'profile', + params: { + path: '/profile/:userId', + loader: async () => ( +
+ Profile Page + +
+ ), + }, + }); + + 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
{location.pathname}
; + }; + + const docsPage = PageBlueprint.make({ + name: 'docs', + params: { + path: '/docs', + loader: async () => ( +
+ Docs Page + +
+ ), + }, + }); + + 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
{location.pathname}
; + }; + + const targetPage = PageBlueprint.make({ + name: 'target', + params: { + path: '/target/:ab/:a', + loader: async () => ( +
+ Target Page + +
+ ), + }, + }); + + 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', diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index 75d590283b..04d94ebc35 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -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; + 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 ; +} export const AppRoutes = createExtension({ name: 'routes', @@ -54,7 +68,7 @@ export const AppRoutes = createExtension({ redirect.from === '/' ? redirect.from : `${redirect.from.replace(/\/$/, '')}/*`, - element: , + element: , })), ...inputs.routes.map(route => { const routePath = route.get(coreExtensionData.routePath);