Merge pull request #33670 from backstage/rugvip/app-routes-redirect-config
plugin-app: add redirect config to app/routes extension
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 `<Redirect>` routes
|
||||
|
||||
If your old routes include `<Redirect>` 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.
|
||||
|
||||
@@ -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<JSX_2.Element, 'core.reactElement', {}>;
|
||||
inputs: {
|
||||
routes: ExtensionInput<
|
||||
|
||||
@@ -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 <div data-testid="location">{location.pathname}</div>;
|
||||
};
|
||||
|
||||
const catalogPage = PageBlueprint.make({
|
||||
name: 'catalog',
|
||||
params: {
|
||||
path: '/catalog',
|
||||
loader: async () => (
|
||||
<div>
|
||||
Catalog Page
|
||||
<LocationDisplay />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
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 <div data-testid="location">{location.pathname}</div>;
|
||||
};
|
||||
|
||||
const catalogPage = PageBlueprint.make({
|
||||
name: 'catalog',
|
||||
params: {
|
||||
path: '/catalog',
|
||||
loader: async () => (
|
||||
<div>
|
||||
Catalog Page
|
||||
<LocationDisplay />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const docsPage = PageBlueprint.make({
|
||||
name: 'docs',
|
||||
params: {
|
||||
path: '/docs',
|
||||
loader: async () => (
|
||||
<div>
|
||||
Docs Page
|
||||
<LocationDisplay />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
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 <div data-testid="location">{location.pathname}</div>;
|
||||
};
|
||||
|
||||
const catalogPage = PageBlueprint.make({
|
||||
name: 'catalog',
|
||||
params: {
|
||||
path: '/catalog',
|
||||
loader: async () => (
|
||||
<div>
|
||||
Catalog Page
|
||||
<LocationDisplay />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const homePage = PageBlueprint.make({
|
||||
name: 'home',
|
||||
params: {
|
||||
path: '/home',
|
||||
loader: async () => (
|
||||
<div>
|
||||
Home Page
|
||||
<LocationDisplay />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
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 () => <div>Home Page</div>,
|
||||
},
|
||||
});
|
||||
|
||||
const catalogPage = PageBlueprint.make({
|
||||
name: 'catalog',
|
||||
params: {
|
||||
path: '/catalog',
|
||||
loader: async () => <div>Catalog Page</div>,
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <Navigate to={redirect.to} replace />,
|
||||
})),
|
||||
...inputs.routes.map(route => {
|
||||
const routePath = route.get(coreExtensionData.routePath);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user