diff --git a/.changeset/app-routes-redirect-config.md b/.changeset/app-routes-redirect-config.md new file mode 100644 index 0000000000..ce43e3c159 --- /dev/null +++ b/.changeset/app-routes-redirect-config.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-app': patch +--- + +Added support for configuring URL redirects on the `app/routes` extension. Redirects can be configured through `app-config` as an array of `{from, to}` path pairs, which will cause navigation to the `from` path to be redirected to the `to` path. + +For example: + +```yaml +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path +``` diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md index 05e60b54b7..637aded411 100644 --- a/docs/frontend-system/building-apps/03-built-in-extensions.md +++ b/docs/frontend-system/building-apps/03-built-in-extensions.md @@ -183,6 +183,30 @@ Be careful when overriding this extension, as to do so correctly you must consid - Remember to user the route refs for getting paths dynamically, otherwise if an adopter modifies a path through configuration, the route is not going to point to the configured path; - Adopters expect to be able to customize the `NotFoundErrorPage` component via Components API, you should render this component for routes not configured. +#### Configurations + +| Key | Type | Default value | Description | +| ----------- | -------------------------------- | ------------- | -------------------------------------------------------------------- | +| `redirects` | `{ from: string, to: string }[]` | - | A list of URL redirects. Navigation to `from` will redirect to `to`. | + +##### Configuring redirects + +You can configure redirects for the `app/routes` extension to automatically redirect users from one path to another. This is useful when restructuring your app's routes, for example after moving or renaming plugin pages. + +```yaml title="app-config.yaml" +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path + - from: /legacy/page + to: /updated/page +``` + +Redirects are matched before any regular routes and use the [`replace`](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState) strategy, so the old path will not appear in the browser history. + #### Inputs | Name | Description | Type | Optional | Default | Extension creator | diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index ceba8fd868..2784b10b7a 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -800,6 +800,22 @@ If you are using [app feature discovery](../architecture/10-app.md#feature-disco Continue this process for each of your legacy routes until you have migrated all of them. For any plugin with additional extensions installed as children of the `Route`, refer to the plugin READMEs for more detailed instructions. For the entity pages, refer to the [separate section](#catalog-entity-page). +##### Migrating `` routes + +If your old routes include `` elements to forward users from one path to another, you can replace them with the built-in redirect configuration on the `app/routes` extension: + +```yaml title="app-config.yaml" +app: + extensions: + - app/routes: + config: + redirects: + - from: /old-path + to: /new-path +``` + +See the [`app/routes` built-in extension documentation](./03-built-in-extensions.md#configuring-redirects) for more details. + ### Migrating core, internal and third-party plugins For certain core plugins — such as the Catalog plugin's entity page — we provide a dedicated step-by-step migration guide, since these plugins often require a more gradual approach due to their complexity. diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 4c9b2a8ce4..12f9866aac 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -177,8 +177,22 @@ const appPlugin: OverridableFrontendPlugin< name: 'root'; }>; 'app/routes': OverridableExtensionDefinition<{ - config: {}; - configInput: {}; + config: { + redirects: + | { + from: string; + to: string; + }[] + | undefined; + }; + configInput: { + redirects?: + | { + from: string; + to: string; + }[] + | undefined; + }; output: ExtensionDataRef; inputs: { routes: ExtensionInput< diff --git a/plugins/app/src/extensions/AppRoutes.test.tsx b/plugins/app/src/extensions/AppRoutes.test.tsx index 26ba3df447..9aa6474189 100644 --- a/plugins/app/src/extensions/AppRoutes.test.tsx +++ b/plugins/app/src/extensions/AppRoutes.test.tsx @@ -19,6 +19,11 @@ import { renderTestApp } from '@backstage/frontend-test-utils'; import { PageBlueprint } from '@backstage/frontend-plugin-api'; import { Link, useLocation, useParams } from 'react-router-dom'; +const DEFAULT_CONFIG = { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, +}; + describe('AppRoutes', () => { it('should render the first route at root path', async () => { const homePage = PageBlueprint.make({ @@ -243,4 +248,240 @@ describe('AppRoutes', () => { expect(screen.queryByTestId('catalog-page')).not.toBeInTheDocument(); }); }); + + it('should redirect from one path to another using configured redirects', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + renderTestApp({ + extensions: [catalogPage], + initialRouteEntries: ['/old-catalog'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/old-catalog', to: '/catalog' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + }); + + it('should support multiple redirects', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + const docsPage = PageBlueprint.make({ + name: 'docs', + params: { + path: '/docs', + loader: async () => ( +
+ Docs Page + +
+ ), + }, + }); + + const redirectsConfig = { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [ + { from: '/old-catalog', to: '/catalog' }, + { from: '/old-docs', to: '/docs' }, + ], + }, + }, + }, + ], + }, + }; + + const { unmount } = renderTestApp({ + extensions: [catalogPage, docsPage], + initialRouteEntries: ['/old-catalog'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + + unmount(); + + renderTestApp({ + extensions: [catalogPage, docsPage], + initialRouteEntries: ['/old-docs'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Docs Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/docs'); + }); + }); + + it('should only redirect the root path when from is /', async () => { + const LocationDisplay = () => { + const location = useLocation(); + return
{location.pathname}
; + }; + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () => ( +
+ Catalog Page + +
+ ), + }, + }); + + const homePage = PageBlueprint.make({ + name: 'home', + params: { + path: '/home', + loader: async () => ( +
+ Home Page + +
+ ), + }, + }); + + const redirectsConfig = { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/', to: '/home' }], + }, + }, + }, + ], + }, + }; + + const { unmount } = renderTestApp({ + extensions: [catalogPage, homePage], + initialRouteEntries: ['/'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/home'); + }); + + unmount(); + + renderTestApp({ + extensions: [catalogPage, homePage], + initialRouteEntries: ['/catalog'], + config: redirectsConfig, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent('/catalog'); + }); + }); + + it('should not interfere with normal routes when redirects are configured', async () => { + const homePage = PageBlueprint.make({ + name: 'home', + params: { + path: '/', + loader: async () =>
Home Page
, + }, + }); + + const catalogPage = PageBlueprint.make({ + name: 'catalog', + params: { + path: '/catalog', + loader: async () =>
Catalog Page
, + }, + }); + + renderTestApp({ + extensions: [homePage, catalogPage], + initialRouteEntries: ['/catalog'], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [ + { + 'app/routes': { + config: { + redirects: [{ from: '/old-catalog', to: '/catalog' }], + }, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByText('Catalog Page')).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/app/src/extensions/AppRoutes.tsx b/plugins/app/src/extensions/AppRoutes.tsx index ddd7962004..f73c47e2cb 100644 --- a/plugins/app/src/extensions/AppRoutes.tsx +++ b/plugins/app/src/extensions/AppRoutes.tsx @@ -20,7 +20,7 @@ import { createExtensionInput, NotFoundErrorPage, } from '@backstage/frontend-plugin-api'; -import { useRoutes } from 'react-router-dom'; +import { Navigate, useRoutes } from 'react-router-dom'; export const AppRoutes = createExtension({ name: 'routes', @@ -32,10 +32,32 @@ export const AppRoutes = createExtension({ coreExtensionData.reactElement, ]), }, + config: { + schema: { + redirects: z => + z + .array( + z.object({ + from: z.string(), + to: z.string(), + }), + ) + .optional(), + }, + }, output: [coreExtensionData.reactElement], - factory({ inputs }) { + factory({ inputs, config }) { + const redirects = config.redirects ?? []; + const Routes = () => { const element = useRoutes([ + ...redirects.map(redirect => ({ + path: + redirect.from === '/' + ? redirect.from + : `${redirect.from.replace(/\/$/, '')}/*`, + element: , + })), ...inputs.routes.map(route => { const routePath = route.get(coreExtensionData.routePath);