Merge pull request #31804 from backstage/rugvip/compat-route-refs
core-plugin-api: add forwards compatibility for route refs
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/frontend-app-api': patch
|
||||
'@backstage/core-compat-api': patch
|
||||
---
|
||||
|
||||
Internal refactor of route reference implementations with minor updates to the `toString` implementations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-plugin-api': minor
|
||||
---
|
||||
|
||||
All route references are now forwards compatible with the new frontend system, i.e. `@backstage/frontend-plugin-api`. This means they no longer need to be converted with `convertLegacyRouteRef` or `convertLegacyRouteRefs` from `@backstage/core-compat-api`.
|
||||
@@ -9,7 +9,7 @@ description: How to migrate existing apps to the new frontend system
|
||||
|
||||
This section describes how to migrate an existing Backstage app package to use the new frontend system. The app package is typically found at `packages/app` in your project and is responsible for wiring together the Backstage frontend application.
|
||||
|
||||
> **Who is this for?**
|
||||
> **Who is this for?**
|
||||
> This guide is intended for maintainers of Backstage app packages (`packages/app`) who want to upgrade from the legacy frontend system to the new extension-based architecture.
|
||||
|
||||
> **Prerequisites:**
|
||||
@@ -22,10 +22,10 @@ This section describes how to migrate an existing Backstage app package to use t
|
||||
|
||||
We recommend a **two-phase migration process** to ensure a smooth and manageable transition:
|
||||
|
||||
- **Phase 1: Minimal Changes for Hybrid Configuration**
|
||||
- **Phase 1: Minimal Changes for Hybrid Configuration**
|
||||
In this phase, you make the smallest set of changes necessary to enable your app to run in a hybrid mode. This allows you to start using the new frontend system while still relying on compatibility helpers and legacy code. The goal is to unblock your migration quickly, so you can benefit from the new system without a full rewrite.
|
||||
|
||||
- **Phase 2: Complete Transition to the New Frontend System**
|
||||
- **Phase 2: Complete Transition to the New Frontend System**
|
||||
After your app is running in hybrid mode, you can gradually refactor your codebase to remove legacy code and compatibility helpers. This phase focuses on fully adopting the new frontend architecture, ensuring your codebase is clean, maintainable, and takes full advantage of the new features.
|
||||
|
||||
:::warning
|
||||
@@ -157,36 +157,6 @@ const app = createApp({
|
||||
});
|
||||
```
|
||||
|
||||
If you were binding routes from a legacy `createApp`, you will need to use the `convertLegacyRouteRefs` and/or `convertLegacyRouteRef` to convert the routes to be compatible with the new system.
|
||||
|
||||
For example, if both the `catalogPlugin` and `scaffolderPlugin` are legacy plugins, you can bind their routes like this:
|
||||
|
||||
```ts
|
||||
import { createApp } from '@backstage/frontend-defaults';
|
||||
import {
|
||||
// ...
|
||||
convertLegacyRouteRefs,
|
||||
convertLegacyRouteRef,
|
||||
} from '@backstage/core-compat-api';
|
||||
|
||||
// Ommitting converted options changes
|
||||
//...
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
// ...
|
||||
convertedOptionsModule,
|
||||
],
|
||||
// highlight-add-start
|
||||
bindRoutes({ bind }) {
|
||||
bind(convertLegacyRouteRefs(catalogPlugin.externalRoutes), {
|
||||
createComponent: convertLegacyRouteRef(scaffolderPlugin.routes.root),
|
||||
});
|
||||
},
|
||||
// highlight-add-end
|
||||
});
|
||||
```
|
||||
|
||||
### 3) Fixing the `app.createRoot` call
|
||||
|
||||
The `app.createRoot(...)` no longer accepts any arguments. This represents a fundamental change that the new frontend system introduces. In the old system the app element tree that you passed to `app.createRoot(...)` was the primary way that you installed and configured plugins and features in your app. In the new system this is instead replaced by extensions that are wired together into an extension tree. Much more responsibility has now been shifted to plugins, for example you no longer have to manually provide the route path for each plugin page, but instead only configure it if you want to override the default. For more information on how the new system works, see the [architecture](../architecture/00-index.md) section.
|
||||
@@ -590,21 +560,6 @@ const app = createApp({
|
||||
|
||||
Route bindings can still be done using this option, but you now also have the ability to bind routes using static configuration instead. See the section on [binding routes](../architecture/36-routes.md#binding-external-route-references) for more information.
|
||||
|
||||
Note that if you are binding routes from a legacy plugin that was converted using `convertLegacyAppRoot`, you will need to use the `convertLegacyRouteRefs` and/or `convertLegacyRouteRef` to convert the routes to be compatible with the new system.
|
||||
|
||||
For example, if both the `catalogPlugin` and `scaffolderPlugin` are legacy plugins, you can bind their routes like this:
|
||||
|
||||
```ts
|
||||
const app = createApp({
|
||||
features: convertLegacyAppRoot(...),
|
||||
bindRoutes({ bind }) {
|
||||
bind(convertLegacyRouteRefs(catalogPlugin.externalRoutes), {
|
||||
createComponent: convertLegacyRouteRef(scaffolderPlugin.routes.root),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### `__experimentalTranslations`
|
||||
|
||||
Translations are now installed as extensions, created using `TranslationBlueprint`.
|
||||
|
||||
@@ -38,7 +38,6 @@ In order to migrate the actual definition of the plugin you need to recreate the
|
||||
|
||||
```ts title="my-plugin/src/alpha.tsx"
|
||||
import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { convertLegacyRouteRefs } from '@backstage/core-compat-api';
|
||||
|
||||
export default createFrontendPlugin({
|
||||
// The plugin ID is now provided as `pluginId` instead of `id`
|
||||
@@ -47,15 +46,12 @@ In order to migrate the actual definition of the plugin you need to recreate the
|
||||
// bind all the extensions to the plugin
|
||||
/* highlight-next-line */
|
||||
extensions: [/* APIs will go here, but don't worry about those yet */],
|
||||
// convert old route refs to the new system
|
||||
/* highlight-next-line */
|
||||
routes: convertLegacyRouteRefs({
|
||||
routes: {
|
||||
...
|
||||
}),
|
||||
/* highlight-next-line */
|
||||
externalRoutes: convertLegacyRouteRefs({
|
||||
},
|
||||
externalRoutes: {
|
||||
...
|
||||
}),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -110,18 +106,15 @@ it can be migrated as the following, keeping in mind that you may need to switch
|
||||
|
||||
```tsx
|
||||
import { PageBlueprint } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
compatWrapper,
|
||||
convertLegacyRouteRef,
|
||||
} from '@backstage/core-compat-api';
|
||||
import { compatWrapper } from '@backstage/core-compat-api';
|
||||
|
||||
const fooPage = PageBlueprint.make({
|
||||
params: {
|
||||
// This is the path that was previously defined in the app code.
|
||||
// It's labelled as the default one because it can be changed via configuration.
|
||||
path: '/foo',
|
||||
// You can reuse the existing routeRef by wrapping it with convertLegacyRouteRef.
|
||||
routeRef: convertLegacyRouteRef(rootRouteRef),
|
||||
// You can reuse the existing routeRef.
|
||||
routeRef: rootRouteRef,
|
||||
// these inputs usually match the props required by the component.
|
||||
loader: () =>
|
||||
import('./components/').then(m =>
|
||||
|
||||
@@ -34,8 +34,10 @@
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"lodash": "^4.17.21"
|
||||
"lodash": "^4.17.21",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
|
||||
@@ -31,13 +31,11 @@ import {
|
||||
createExternalRouteRef as createNewExternalRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { convertLegacyRouteRef } from './convertLegacyRouteRef';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalRouteRef as toInternalNewRouteRef } from '../../frontend-plugin-api/src/routing/RouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalSubRouteRef as toInternalNewSubRouteRef } from '../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExternalRouteRef as toInternalNewExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
import {
|
||||
OpaqueExternalRouteRef,
|
||||
OpaqueRouteRef,
|
||||
OpaqueSubRouteRef,
|
||||
} from '@internal/frontend';
|
||||
|
||||
describe('convertLegacyRouteRef', () => {
|
||||
it('converts old to new', () => {
|
||||
@@ -85,48 +83,40 @@ describe('convertLegacyRouteRef', () => {
|
||||
expect(ref3).toBe(ref3Converted);
|
||||
expect(ref4).toBe(ref4Converted);
|
||||
|
||||
const ref1Internal = toInternalNewRouteRef(ref1Converted);
|
||||
const ref2Internal = toInternalNewRouteRef(ref2Converted);
|
||||
const ref1sub1Internal = toInternalNewSubRouteRef(ref1sub1Converted);
|
||||
const ref1sub2Internal = toInternalNewSubRouteRef(ref1sub2Converted);
|
||||
const ref2sub1Internal = toInternalNewSubRouteRef(ref2sub1Converted);
|
||||
const ref3Internal = toInternalNewExternalRouteRef(ref3Converted);
|
||||
const ref4Internal = toInternalNewExternalRouteRef(ref4Converted);
|
||||
const ref1Internal = OpaqueRouteRef.toInternal(ref1Converted);
|
||||
const ref2Internal = OpaqueRouteRef.toInternal(ref2Converted);
|
||||
const ref1sub1Internal = OpaqueSubRouteRef.toInternal(ref1sub1Converted);
|
||||
const ref1sub2Internal = OpaqueSubRouteRef.toInternal(ref1sub2Converted);
|
||||
const ref2sub1Internal = OpaqueSubRouteRef.toInternal(ref2sub1Converted);
|
||||
const ref3Internal = OpaqueExternalRouteRef.toInternal(ref3Converted);
|
||||
const ref4Internal = OpaqueExternalRouteRef.toInternal(ref4Converted);
|
||||
|
||||
expect(ref1Internal.getDescription()).toBe(
|
||||
'routeRef{type=absolute,id=ref1}',
|
||||
);
|
||||
expect(ref1Internal.getDescription()).toBe('ref1');
|
||||
expect(ref1Internal.getParams()).toEqual([]);
|
||||
expect(ref2Internal.getDescription()).toBe(
|
||||
'routeRef{type=absolute,id=ref2}',
|
||||
);
|
||||
expect(ref2Internal.getDescription()).toBe('ref2');
|
||||
expect(ref2Internal.getParams()).toEqual(['p1', 'p2']);
|
||||
|
||||
expect(ref1sub1Internal.getDescription()).toBe(
|
||||
'routeRef{type=sub,id=sub1}',
|
||||
'at /sub1 with parent routeRef{type=absolute,id=ref1}',
|
||||
);
|
||||
expect(ref1sub1Internal.getParams()).toEqual([]);
|
||||
expect(ref1sub1Internal.getParent()).toBe(ref1);
|
||||
expect(ref1sub2Internal.getDescription()).toBe(
|
||||
'routeRef{type=sub,id=sub2}',
|
||||
'at /sub2/:p3 with parent routeRef{type=absolute,id=ref1}',
|
||||
);
|
||||
expect(ref1sub2Internal.getParams()).toEqual(['p3']);
|
||||
expect(ref1sub2Internal.getParent()).toBe(ref1);
|
||||
expect(ref2sub1Internal.getDescription()).toBe(
|
||||
'routeRef{type=sub,id=sub1}',
|
||||
'at /sub1/:p3 with parent routeRef{type=absolute,id=ref2}',
|
||||
);
|
||||
expect(ref2sub1Internal.getParams()).toEqual(['p1', 'p2', 'p3']);
|
||||
expect(ref2sub1Internal.getParent()).toBe(ref2);
|
||||
|
||||
expect(ref3Internal.getDefaultTarget()).toBe(undefined);
|
||||
expect(ref3Internal.getDescription()).toBe(
|
||||
'routeRef{type=external,id=ref3}',
|
||||
);
|
||||
expect(ref3Internal.getDescription()).toBe('ref3');
|
||||
expect(ref3Internal.getParams()).toEqual([]);
|
||||
expect(ref4Internal.getDefaultTarget()).toBe('ref2');
|
||||
expect(ref4Internal.getDescription()).toBe(
|
||||
'routeRef{type=external,id=ref4}',
|
||||
);
|
||||
expect(ref4Internal.getDescription()).toBe('ref4');
|
||||
expect(ref4Internal.getParams()).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
@@ -168,34 +158,34 @@ describe('convertLegacyRouteRef', () => {
|
||||
expect(ref3).toBe(ref3Converted);
|
||||
expect(ref4).toBe(ref4Converted);
|
||||
|
||||
expect(String(ref1Converted)).toMatch(/^RouteRef\{created at '.*'\}$/);
|
||||
expect(String(ref1Converted)).toMatch(/^routeRef\{id=undefined,at='.*'\}$/);
|
||||
expect(ref1Converted.params).toEqual([]);
|
||||
expect(String(ref2Converted)).toMatch(/^RouteRef\{created at '.*'\}$/);
|
||||
expect(String(ref2Converted)).toMatch(/^routeRef\{id=undefined,at='.*'\}$/);
|
||||
expect(ref2Converted.params).toEqual(['p1', 'p2']);
|
||||
|
||||
expect(String(ref1sub1Converted)).toMatch(
|
||||
/^SubRouteRef\{at \/sub1 with parent created at '.*'\}$/,
|
||||
/^subRouteRef\{path='\/sub1',parent=routeRef\{id=undefined,at='.*'\}\}$/,
|
||||
);
|
||||
expect(ref1sub1Converted.params).toEqual([]);
|
||||
expect(ref1sub1Converted.parent).toBe(ref1);
|
||||
expect(String(ref1sub2Converted)).toMatch(
|
||||
/^SubRouteRef\{at \/sub2\/:p3 with parent created at '.*'\}$/,
|
||||
/^subRouteRef\{path='\/sub2\/:p3',parent=routeRef\{id=undefined,at='.*'\}\}$/,
|
||||
);
|
||||
expect(ref1sub2Converted.params).toEqual(['p3']);
|
||||
expect(ref1sub2Converted.parent).toBe(ref1);
|
||||
expect(String(ref2sub1Converted)).toMatch(
|
||||
/^SubRouteRef\{at \/sub1\/:p3 with parent created at '.*'\}$/,
|
||||
/^subRouteRef\{path='\/sub1\/:p3',parent=routeRef\{id=undefined,at='.*'\}\}$/,
|
||||
);
|
||||
expect(ref2sub1Converted.params).toEqual(['p1', 'p2', 'p3']);
|
||||
expect(ref2sub1Converted.parent).toBe(ref2);
|
||||
|
||||
expect(String(ref3Converted)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*'\}$/,
|
||||
/^externalRouteRef\{id=undefined,at='.*'\}$/,
|
||||
);
|
||||
expect(ref3Converted.params).toEqual([]);
|
||||
expect(ref3Converted.optional).toBe(true);
|
||||
expect(String(ref4Converted)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*'\}$/,
|
||||
/^externalRouteRef\{id=undefined,at='.*'\}$/,
|
||||
);
|
||||
expect(ref4Converted.params).toEqual(['p1', 'p2']);
|
||||
expect(ref4Converted.optional).toBe(true);
|
||||
|
||||
@@ -32,13 +32,11 @@ import {
|
||||
createSubRouteRef,
|
||||
createExternalRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalRouteRef } from '../../frontend-plugin-api/src/routing/RouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalSubRouteRef } from '../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExternalRouteRef } from '../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
import {
|
||||
OpaqueRouteRef,
|
||||
OpaqueSubRouteRef,
|
||||
OpaqueExternalRouteRef,
|
||||
} from '@internal/frontend';
|
||||
|
||||
/**
|
||||
* Converts a legacy route ref type to the new system.
|
||||
@@ -184,29 +182,29 @@ function convertNewToOld(
|
||||
ref: RouteRef | SubRouteRef | ExternalRouteRef,
|
||||
): LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef {
|
||||
if (ref.$$type === '@backstage/RouteRef') {
|
||||
const newRef = toInternalRouteRef(ref);
|
||||
const newRef = OpaqueRouteRef.toInternal(ref);
|
||||
return Object.assign(ref, {
|
||||
[routeRefType]: 'absolute',
|
||||
params: newRef.getParams(),
|
||||
title: newRef.getDescription(),
|
||||
} as Omit<LegacyRouteRef, '$$routeRefType'>) as unknown as LegacyRouteRef;
|
||||
} as Omit<LegacyRouteRef, '$$routeRefType' | keyof RouteRef>) as unknown as LegacyRouteRef;
|
||||
}
|
||||
if (ref.$$type === '@backstage/SubRouteRef') {
|
||||
const newRef = toInternalSubRouteRef(ref);
|
||||
const newRef = OpaqueSubRouteRef.toInternal(ref);
|
||||
return Object.assign(ref, {
|
||||
[routeRefType]: 'sub',
|
||||
parent: convertLegacyRouteRef(newRef.getParent()),
|
||||
params: newRef.getParams(),
|
||||
} as Omit<LegacySubRouteRef, '$$routeRefType' | 'path'>) as unknown as LegacySubRouteRef;
|
||||
} as Omit<LegacySubRouteRef, '$$routeRefType' | keyof SubRouteRef>) as unknown as LegacySubRouteRef;
|
||||
}
|
||||
if (ref.$$type === '@backstage/ExternalRouteRef') {
|
||||
const newRef = toInternalExternalRouteRef(ref);
|
||||
const newRef = OpaqueExternalRouteRef.toInternal(ref);
|
||||
return Object.assign(ref, {
|
||||
[routeRefType]: 'external',
|
||||
optional: true,
|
||||
params: newRef.getParams(),
|
||||
defaultTarget: newRef.getDefaultTarget(),
|
||||
} as Omit<LegacyExternalRouteRef, '$$routeRefType' | 'optional'>) as unknown as LegacyExternalRouteRef;
|
||||
} as Omit<LegacyExternalRouteRef, '$$routeRefType' | keyof ExternalRouteRef>) as unknown as LegacyExternalRouteRef;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
@@ -221,7 +219,7 @@ function convertOldToNew(
|
||||
if (type === 'absolute') {
|
||||
const legacyRef = ref as LegacyRouteRef;
|
||||
const legacyRefStr = String(legacyRef);
|
||||
const newRef = toInternalRouteRef(
|
||||
const newRef = OpaqueRouteRef.toInternal(
|
||||
createRouteRef<{ [key in string]: string }>({
|
||||
params: legacyRef.params as string[],
|
||||
}),
|
||||
@@ -247,7 +245,7 @@ function convertOldToNew(
|
||||
if (type === 'sub') {
|
||||
const legacyRef = ref as LegacySubRouteRef;
|
||||
const legacyRefStr = String(legacyRef);
|
||||
const newRef = toInternalSubRouteRef(
|
||||
const newRef = OpaqueSubRouteRef.toInternal(
|
||||
createSubRouteRef({
|
||||
path: legacyRef.path,
|
||||
parent: convertLegacyRouteRef(legacyRef.parent),
|
||||
@@ -274,7 +272,7 @@ function convertOldToNew(
|
||||
if (type === 'external') {
|
||||
const legacyRef = ref as LegacyExternalRouteRef;
|
||||
const legacyRefStr = String(legacyRef);
|
||||
const newRef = toInternalExternalRouteRef(
|
||||
const newRef = OpaqueExternalRouteRef.toInternal(
|
||||
createExternalRouteRef<{ [key in string]: string }>({
|
||||
params: legacyRef.params as string[],
|
||||
defaultTarget:
|
||||
|
||||
@@ -53,11 +53,13 @@
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"history": "^5.0.0"
|
||||
"history": "^5.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
|
||||
@@ -429,6 +429,8 @@ export type ExternalRouteRef<
|
||||
$$routeRefType: 'external';
|
||||
params: ParamKeys<Params>;
|
||||
optional?: Optional;
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -698,6 +700,8 @@ export type RouteFunc<Params extends AnyParams> = (
|
||||
export type RouteRef<Params extends AnyParams = any> = {
|
||||
$$routeRefType: 'absolute';
|
||||
params: ParamKeys<Params>;
|
||||
readonly $$type: '@backstage/RouteRef';
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -762,6 +766,8 @@ export type SubRouteRef<Params extends AnyParams = any> = {
|
||||
parent: RouteRef;
|
||||
path: string;
|
||||
params: ParamKeys<Params>;
|
||||
readonly $$type: '@backstage/SubRouteRef';
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { AnyParams, ExternalRouteRef } from './types';
|
||||
import { createExternalRouteRef } from './ExternalRouteRef';
|
||||
import { RouteResolutionApi, RouteFunc } from '@backstage/frontend-plugin-api';
|
||||
|
||||
describe('ExternalRouteRef', () => {
|
||||
it('should be created', () => {
|
||||
@@ -24,7 +25,9 @@ describe('ExternalRouteRef', () => {
|
||||
});
|
||||
expect(routeRef.params).toEqual([]);
|
||||
expect(routeRef.optional).toBe(false);
|
||||
expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}');
|
||||
expect(String(routeRef)).toMatch(
|
||||
/^routeRef\{type=external,id=my-route-ref\}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be created as optional', () => {
|
||||
@@ -109,4 +112,48 @@ describe('ExternalRouteRef', () => {
|
||||
// To avoid complains about missing expectations and unused vars
|
||||
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
describe('with new frontend system', () => {
|
||||
const routeResolutionApi = { resolve: jest.fn() } as RouteResolutionApi;
|
||||
|
||||
function expectType<T>(): <U>(
|
||||
v: U,
|
||||
) => [T, U] extends [U, T] ? { ok(): void } : { invalid: U } {
|
||||
return () => ({ ok() {} } as any);
|
||||
}
|
||||
|
||||
it('should resolve routes correctly', () => {
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(createExternalRouteRef({ id: '1' })),
|
||||
).ok();
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createExternalRouteRef({ id: '1', optional: true }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createExternalRouteRef({ id: '1', optional: false }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expectType<RouteFunc<{ x: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createExternalRouteRef({ id: '1', params: ['x'] }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createExternalRouteRef({ id: '1', params: ['x'], optional: true }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createExternalRouteRef({ id: '1', params: ['x'], optional: false }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,12 +52,45 @@ export class ExternalRouteRefImpl<
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.#nfsId) {
|
||||
return `externalRouteRef{id=${this.#nfsId},legacyId=${this.id}}`;
|
||||
}
|
||||
return `routeRef{type=external,id=${this.id}}`;
|
||||
}
|
||||
|
||||
getDefaultTarget() {
|
||||
return this.defaultTarget;
|
||||
}
|
||||
|
||||
// NFS implementation below
|
||||
readonly $$type = '@backstage/ExternalRouteRef';
|
||||
readonly version = 'v1';
|
||||
readonly T = undefined as any;
|
||||
|
||||
#nfsId: string | undefined = undefined;
|
||||
|
||||
getParams(): string[] {
|
||||
return this.params as string[];
|
||||
}
|
||||
getDescription(): string {
|
||||
if (this.#nfsId) {
|
||||
return this.#nfsId;
|
||||
}
|
||||
return this.id;
|
||||
}
|
||||
setId(newId: string) {
|
||||
if (!newId) {
|
||||
throw new Error(`ExternalRouteRef id must be a non-empty string`);
|
||||
}
|
||||
if (this.#nfsId && this.#nfsId !== newId) {
|
||||
throw new Error(
|
||||
`ExternalRouteRef was referenced twice as both '${
|
||||
this.#nfsId
|
||||
}' and '${newId}'`,
|
||||
);
|
||||
}
|
||||
this.#nfsId = newId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,5 +139,5 @@ export function createExternalRouteRef<
|
||||
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
Boolean(options.optional) as Optional,
|
||||
options?.defaultTarget,
|
||||
);
|
||||
) as ExternalRouteRef<OptionalParams<Params>, Optional>;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { AnyParams, RouteRef, ParamKeys } from './types';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
import { RouteResolutionApi, RouteFunc } from '@backstage/frontend-plugin-api';
|
||||
|
||||
describe('RouteRef', () => {
|
||||
it('should be created', () => {
|
||||
@@ -23,7 +24,9 @@ describe('RouteRef', () => {
|
||||
id: 'my-route-ref',
|
||||
});
|
||||
expect(routeRef.params).toEqual([]);
|
||||
expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}');
|
||||
expect(String(routeRef)).toMatch(
|
||||
/^routeRef\{type=absolute,id=my-route-ref\}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
@@ -95,4 +98,37 @@ describe('RouteRef', () => {
|
||||
|
||||
expect(true).toBeDefined();
|
||||
});
|
||||
|
||||
describe('with new frontend system', () => {
|
||||
const routeResolutionApi = { resolve: jest.fn() } as RouteResolutionApi;
|
||||
|
||||
function expectType<T>(): <U>(
|
||||
v: U,
|
||||
) => [T, U] extends [U, T] ? { ok(): void } : { invalid: U } {
|
||||
return () => ({ ok() {} } as any);
|
||||
}
|
||||
|
||||
it('should resolve routes correctly', () => {
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(createRouteRef({ id: '1' })),
|
||||
).ok();
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(createRouteRef({ id: '1' })),
|
||||
).ok();
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(createRouteRef({ id: '1', params: [] })),
|
||||
).ok();
|
||||
|
||||
expectType<RouteFunc<{ x: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(createRouteRef({ id: '1', params: ['x'] })),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string; y: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createRouteRef({ id: '1', params: ['x', 'y'] }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,8 +45,40 @@ export class RouteRefImpl<Params extends AnyParams>
|
||||
}
|
||||
|
||||
toString() {
|
||||
if (this.#nfsId) {
|
||||
return `routeRef{id=${this.#nfsId},legacyId=${this.id}}`;
|
||||
}
|
||||
return `routeRef{type=absolute,id=${this.id}}`;
|
||||
}
|
||||
|
||||
// NFS implementation below
|
||||
readonly $$type = '@backstage/RouteRef';
|
||||
readonly version = 'v1';
|
||||
readonly T = undefined as any;
|
||||
readonly alias = undefined;
|
||||
|
||||
#nfsId: string | undefined = undefined;
|
||||
|
||||
getParams() {
|
||||
return this.params;
|
||||
}
|
||||
getDescription() {
|
||||
if (this.#nfsId) {
|
||||
return this.#nfsId;
|
||||
}
|
||||
return this.id;
|
||||
}
|
||||
setId(newId: string) {
|
||||
if (!newId) {
|
||||
throw new Error(`RouteRef id must be a non-empty string`);
|
||||
}
|
||||
if (this.#nfsId && this.#nfsId !== newId) {
|
||||
throw new Error(
|
||||
`RouteRef was referenced twice as both '${this.#nfsId}' and '${newId}'`,
|
||||
);
|
||||
}
|
||||
this.#nfsId = newId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,5 +104,5 @@ export function createRouteRef<
|
||||
return new RouteRefImpl(
|
||||
config.id,
|
||||
(config.params ?? []) as ParamKeys<OptionalParams<Params>>,
|
||||
);
|
||||
) as RouteRef<OptionalParams<Params>>;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { AnyParams, SubRouteRef } from './types';
|
||||
import { createSubRouteRef } from './SubRouteRef';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
import { RouteResolutionApi, RouteFunc } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const parent = createRouteRef({ id: 'parent' });
|
||||
const parentX = createRouteRef({ id: 'parent-x', params: ['x'] });
|
||||
@@ -31,7 +32,7 @@ describe('SubRouteRef', () => {
|
||||
expect(routeRef.path).toBe('/foo');
|
||||
expect(routeRef.parent).toBe(parent);
|
||||
expect(routeRef.params).toEqual([]);
|
||||
expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}');
|
||||
expect(String(routeRef)).toMatch(/^routeRef\{type=sub,id=my-route-ref\}$/);
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
@@ -125,4 +126,51 @@ describe('SubRouteRef', () => {
|
||||
// To avoid complains about missing expectations and unused vars
|
||||
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
describe('with new frontend system', () => {
|
||||
const routeResolutionApi = { resolve: jest.fn() } as RouteResolutionApi;
|
||||
|
||||
function expectType<T>(): <U>(
|
||||
v: U,
|
||||
) => [T, U] extends [U, T] ? { ok(): void } : { invalid: U } {
|
||||
return () => ({ ok() {} } as any);
|
||||
}
|
||||
|
||||
it('should resolve routes correctly', () => {
|
||||
expectType<RouteFunc<undefined> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent, path: '/foo' }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent: parentX, path: '/foo' }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expectType<RouteFunc<{ y: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent, path: '/:y' }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string; y: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent: parentX, path: '/:y' }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expectType<RouteFunc<{ y: string; z: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent, path: '/:y/:z' }),
|
||||
),
|
||||
).ok();
|
||||
expectType<RouteFunc<{ x: string; y: string; z: string }> | undefined>()(
|
||||
routeResolutionApi.resolve(
|
||||
createSubRouteRef({ id: '1', parent: parentX, path: '/:y/:z' }),
|
||||
),
|
||||
).ok();
|
||||
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,21 @@ export class SubRouteRefImpl<Params extends AnyParams>
|
||||
toString() {
|
||||
return `routeRef{type=sub,id=${this.id}}`;
|
||||
}
|
||||
|
||||
// NFS implementation below
|
||||
readonly $$type = '@backstage/SubRouteRef';
|
||||
readonly version = 'v1';
|
||||
readonly T = undefined as any;
|
||||
|
||||
getParams(): string[] {
|
||||
return this.params as string[];
|
||||
}
|
||||
getParent(): RouteRef {
|
||||
return this.parent;
|
||||
}
|
||||
getDescription(): string {
|
||||
return `at ${this.path} with parent ${this.parent}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,7 +170,7 @@ export function createSubRouteRef<
|
||||
path,
|
||||
parent,
|
||||
params as ParamKeys<MergeParams<Params, ParentParams>>,
|
||||
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
|
||||
);
|
||||
|
||||
// But skip type checking of the return value itself, because the conditional
|
||||
// type checking of the parent parameter overlap is tricky to express.
|
||||
|
||||
@@ -98,6 +98,11 @@ export type RouteRef<Params extends AnyParams = any> = {
|
||||
|
||||
/** @deprecated access to this property will be removed in the future */
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly $$type: '@backstage/RouteRef';
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -120,6 +125,11 @@ export type SubRouteRef<Params extends AnyParams = any> = {
|
||||
|
||||
/** @deprecated access to this property will be removed in the future */
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly $$type: '@backstage/SubRouteRef';
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -142,6 +152,11 @@ export type ExternalRouteRef<
|
||||
params: ParamKeys<Params>;
|
||||
|
||||
optional?: Optional;
|
||||
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly $$type: '@backstage/ExternalRouteRef';
|
||||
/** Compatibility field for new frontend system */
|
||||
readonly T: Params;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { RouteRefsById } from './collectRouteIds';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef';
|
||||
import { OpaqueRouteRef } from '@internal/frontend';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -41,7 +40,7 @@ export function createRouteAliasResolver(
|
||||
|
||||
let currentRef = routeRef;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const alias = toInternalRouteRef(currentRef).alias;
|
||||
const alias = OpaqueRouteRef.toInternal(currentRef).alias;
|
||||
if (alias) {
|
||||
if (pluginId) {
|
||||
const [aliasPluginId] = alias.split('.');
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
RouteRef,
|
||||
SubRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
BackstagePlugin,
|
||||
createRouteRef as createLegacyRouteRef,
|
||||
createSubRouteRef as createLegacySubRouteRef,
|
||||
createExternalRouteRef as createLegacyExternalRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { RouteResolver } from './RouteResolver';
|
||||
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
|
||||
import {
|
||||
@@ -416,4 +421,196 @@ describe('RouteResolver', () => {
|
||||
'/my-parent/a%2F%23%26%3Fb',
|
||||
);
|
||||
});
|
||||
|
||||
describe('with legacy route refs', () => {
|
||||
const legacyRef1 = createLegacyRouteRef({ id: 'ref1' });
|
||||
const legacyRef2 = createLegacyRouteRef({ id: 'ref2', params: ['x'] });
|
||||
const legacyRef3 = createLegacyRouteRef({ id: 'ref3', params: ['y'] });
|
||||
const legacySubRef1 = createLegacySubRouteRef({
|
||||
id: 'sub1',
|
||||
parent: legacyRef1,
|
||||
path: '/foo',
|
||||
});
|
||||
const legacySubRef2 = createLegacySubRouteRef({
|
||||
id: 'sub2',
|
||||
parent: legacyRef1,
|
||||
path: '/foo/:a',
|
||||
});
|
||||
const legacySubRef3 = createLegacySubRouteRef({
|
||||
id: 'sub3',
|
||||
parent: legacyRef2,
|
||||
path: '/bar',
|
||||
});
|
||||
const legacySubRef4 = createLegacySubRouteRef({
|
||||
id: 'sub4',
|
||||
parent: legacyRef2,
|
||||
path: '/bar/:a',
|
||||
});
|
||||
const legacyExternalRef1 = createLegacyExternalRouteRef({
|
||||
id: 'external1',
|
||||
});
|
||||
const legacyExternalRef2 = createLegacyExternalRouteRef({
|
||||
id: 'external2',
|
||||
params: ['x'],
|
||||
});
|
||||
|
||||
it('should not resolve anything with an empty resolver', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map(),
|
||||
new Map(),
|
||||
[],
|
||||
new Map(),
|
||||
'',
|
||||
emptyResolver,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(r.resolve(legacyRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '5x' })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map([[legacyRef1, 'my-route']]),
|
||||
new Map(),
|
||||
[{ routeRefs: new Set([legacyRef1]), path: 'my-route', ...rest }],
|
||||
new Map(),
|
||||
'',
|
||||
emptyResolver,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(r.resolve(legacyRef1, src('/'))?.()).toBe('/my-route');
|
||||
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe('/my-route/foo');
|
||||
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(
|
||||
'/my-route/foo/2a',
|
||||
);
|
||||
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(undefined);
|
||||
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe(undefined);
|
||||
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '5x' })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve an absolute route with a param and with a parent', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[legacyRef1, 'my-route'],
|
||||
[legacyRef2, 'my-parent/:x'],
|
||||
]),
|
||||
new Map([[legacyRef2, legacyRef1]]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([legacyRef2]),
|
||||
path: 'my-parent/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{ routeRefs: new Set([legacyRef1]), path: 'my-route', ...rest },
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
|
||||
[legacyExternalRef1, legacyRef1],
|
||||
[legacyExternalRef2, legacySubRef3],
|
||||
]),
|
||||
'',
|
||||
emptyResolver,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(r.resolve(legacyRef1, src('/'))?.()).toBe('/my-route');
|
||||
expect(r.resolve(legacyRef2, src('/'))?.({ x: '1x' })).toBe(
|
||||
'/my-route/my-parent/1x',
|
||||
);
|
||||
expect(r.resolve(legacySubRef1, src('/'))?.()).toBe('/my-route/foo');
|
||||
expect(r.resolve(legacySubRef2, src('/'))?.({ a: '2a' })).toBe(
|
||||
'/my-route/foo/2a',
|
||||
);
|
||||
expect(r.resolve(legacySubRef3, src('/'))?.({ x: '3x' })).toBe(
|
||||
'/my-route/my-parent/3x/bar',
|
||||
);
|
||||
expect(r.resolve(legacySubRef4, src('/'))?.({ x: '4x', a: '4a' })).toBe(
|
||||
'/my-route/my-parent/4x/bar/4a',
|
||||
);
|
||||
expect(r.resolve(legacyExternalRef1, src('/'))?.()).toBe('/my-route');
|
||||
expect(r.resolve(legacyExternalRef2, src('/'))?.({ x: '6x' })).toBe(
|
||||
'/my-route/my-parent/6x/bar',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve the most specific match', () => {
|
||||
const r = new RouteResolver(
|
||||
new Map<RouteRef, string>([
|
||||
[legacyRef1, 'deep'],
|
||||
[legacyRef2, 'root/:x'],
|
||||
[legacyRef3, 'sub/:y'],
|
||||
]),
|
||||
new Map<RouteRef, RouteRef>([
|
||||
[legacyRef3, legacyRef2],
|
||||
[legacyRef1, legacyRef3],
|
||||
]),
|
||||
[
|
||||
{
|
||||
routeRefs: new Set([legacyRef2]),
|
||||
path: 'root/:x',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([legacyRef3]),
|
||||
path: 'sub/:y',
|
||||
...rest,
|
||||
children: [
|
||||
MATCH_ALL_ROUTE,
|
||||
{
|
||||
routeRefs: new Set([legacyRef1]),
|
||||
path: 'deep',
|
||||
...rest,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
|
||||
'',
|
||||
emptyResolver,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(r.resolve(legacyRef2, src('/'))?.({ x: 'x' })).toBe('/root/x');
|
||||
expect(r.resolve(legacyRef3, src('/root/x'))?.({ y: 'y' })).toBe(
|
||||
'/root/x/sub/y',
|
||||
);
|
||||
|
||||
expect(() => r.resolve(legacyRef1, src('/'))?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(() => r.resolve(legacyRef1, src('/root/x'))?.()).toThrow(
|
||||
/^Cannot route.*with parent.*as it has parameters$/,
|
||||
);
|
||||
expect(r.resolve(legacyRef1, src('/root/x/sub/y'))?.()).toBe(
|
||||
'/root/x/sub/y/deep',
|
||||
);
|
||||
// Without the MATCH_ALL_ROUTE, we wouldn't properly match the route here
|
||||
expect(
|
||||
r.resolve(legacyRef1, src('/root/x/sub/y/any/nested/path/here'))?.(),
|
||||
).toBe('/root/x/sub/y/deep');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,18 +25,11 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { AnyRouteRef, BackstageRouteObject } from './types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { isRouteRef } from '../../../frontend-plugin-api/src/routing/RouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isSubRouteRef,
|
||||
toInternalSubRouteRef,
|
||||
} from '../../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isExternalRouteRef,
|
||||
toInternalExternalRouteRef,
|
||||
} from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
OpaqueRouteRef,
|
||||
OpaqueExternalRouteRef,
|
||||
OpaqueSubRouteRef,
|
||||
} from '@internal/frontend';
|
||||
import { RouteAliasResolver } from './RouteAliasResolver';
|
||||
|
||||
// Joins a list of paths together, avoiding trailing and duplicate slashes
|
||||
@@ -65,10 +58,10 @@ function resolveTargetRef(
|
||||
let ref: AnyRouteRef = targetRouteRef;
|
||||
let path = '';
|
||||
|
||||
if (isExternalRouteRef(ref)) {
|
||||
if (OpaqueExternalRouteRef.isType(ref)) {
|
||||
let resolvedRoute = routeBindings.get(ref);
|
||||
if (!resolvedRoute) {
|
||||
const internal = toInternalExternalRouteRef(ref);
|
||||
const internal = OpaqueExternalRouteRef.toInternal(ref);
|
||||
const defaultTarget = internal.getDefaultTarget();
|
||||
if (defaultTarget) {
|
||||
resolvedRoute = routeRefsById.get(defaultTarget);
|
||||
@@ -80,13 +73,13 @@ function resolveTargetRef(
|
||||
ref = resolvedRoute;
|
||||
}
|
||||
|
||||
if (isSubRouteRef(ref)) {
|
||||
const internal = toInternalSubRouteRef(ref);
|
||||
if (OpaqueSubRouteRef.isType(ref)) {
|
||||
const internal = OpaqueSubRouteRef.toInternal(ref);
|
||||
path = ref.path;
|
||||
ref = internal.getParent();
|
||||
}
|
||||
|
||||
if (!isRouteRef(ref)) {
|
||||
if (!OpaqueRouteRef.isType(ref)) {
|
||||
throw new Error(
|
||||
`Unexpectedly resolved ${targetRouteRef} to a non-route ref ${ref}`,
|
||||
);
|
||||
|
||||
@@ -39,10 +39,10 @@ describe('collectRouteIds', () => {
|
||||
const extRef = createExternalRouteRef();
|
||||
|
||||
expect(String(ref)).toMatch(
|
||||
/^RouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
/^routeRef\{id=undefined,at='.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
);
|
||||
expect(String(extRef)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
/^externalRouteRef\{id=undefined,at='.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
);
|
||||
|
||||
const collected = collectRouteIds(
|
||||
@@ -62,8 +62,12 @@ describe('collectRouteIds', () => {
|
||||
'test.extRef': extRef,
|
||||
});
|
||||
|
||||
expect(String(ref)).toBe('RouteRef{test.ref}');
|
||||
expect(String(extRef)).toBe('ExternalRouteRef{test.extRef}');
|
||||
expect(String(ref)).toMatch(
|
||||
/^routeRef\{id=test.ref,at='.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
);
|
||||
expect(String(extRef)).toMatch(
|
||||
/^externalRouteRef\{id=test.extRef,at='.*collectRouteIds\.test\.ts.*'\}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should report duplicate route IDs', () => {
|
||||
|
||||
@@ -20,16 +20,12 @@ import {
|
||||
ExternalRouteRef,
|
||||
FrontendFeature,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
isRouteRef,
|
||||
toInternalRouteRef,
|
||||
} from '../../../frontend-plugin-api/src/routing/RouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
OpaqueRouteRef,
|
||||
OpaqueSubRouteRef,
|
||||
OpaqueExternalRouteRef,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
|
||||
/** @internal */
|
||||
@@ -62,12 +58,12 @@ export function collectRouteIds(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRouteRef(ref)) {
|
||||
const internalRef = toInternalRouteRef(ref);
|
||||
if (OpaqueRouteRef.isType(ref)) {
|
||||
const internalRef = OpaqueRouteRef.toInternal(ref);
|
||||
internalRef.setId(refId);
|
||||
routesById.set(refId, ref);
|
||||
} else {
|
||||
const internalRef = toInternalSubRouteRef(ref);
|
||||
const internalRef = OpaqueSubRouteRef.toInternal(ref);
|
||||
routesById.set(refId, internalRef);
|
||||
}
|
||||
}
|
||||
@@ -82,7 +78,7 @@ export function collectRouteIds(
|
||||
continue;
|
||||
}
|
||||
|
||||
const internalRef = toInternalExternalRouteRef(ref);
|
||||
const internalRef = OpaqueExternalRouteRef.toInternal(ref);
|
||||
internalRef.setId(refId);
|
||||
externalRoutesById.set(refId, ref);
|
||||
}
|
||||
|
||||
@@ -636,7 +636,7 @@ describe('discovery', () => {
|
||||
},
|
||||
),
|
||||
).toThrow(
|
||||
/Refused to resolve alias 'other.root' for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/,
|
||||
/Refused to resolve alias 'other.root' for routeRef{id=undefined,at='.*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'} as it points to a different plugin, the expected plugin is 'test' but the alias points to 'other'/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -662,7 +662,7 @@ describe('discovery', () => {
|
||||
},
|
||||
),
|
||||
).toThrow(
|
||||
/Alias loop detected for RouteRef{created at 'at .*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/,
|
||||
/Alias loop detected for routeRef{id=undefined,at='.*extractRouteInfoFromAppNode\.test\.ts:\d+:\d+'}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,7 @@ import { RouteRefsById } from './collectRouteIds';
|
||||
import { ErrorCollector } from '../wiring/createErrorCollector';
|
||||
import { Config } from '@backstage/config';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
|
||||
import { OpaqueExternalRouteRef } from '@internal/frontend';
|
||||
|
||||
/**
|
||||
* Extracts a union of the keys in a map whose value extends the given type
|
||||
@@ -166,7 +165,7 @@ export function resolveRouteBindings(
|
||||
for (const externalRef of routesById.externalRoutes.values()) {
|
||||
if (!result.has(externalRef) && !disabledExternalRefs.has(externalRef)) {
|
||||
const defaultRefId =
|
||||
toInternalExternalRouteRef(externalRef).getDefaultTarget();
|
||||
OpaqueExternalRouteRef.toInternal(externalRef).getDefaultTarget();
|
||||
if (defaultRefId) {
|
||||
const defaultRef = routesById.routes.get(defaultRefId);
|
||||
if (defaultRef) {
|
||||
|
||||
@@ -14,4 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './routing';
|
||||
export * from './wiring';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export const OpaqueExternalRouteRef = OpaqueType.create<{
|
||||
public: ExternalRouteRef;
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
|
||||
getParams(): string[];
|
||||
getDescription(): string;
|
||||
getDefaultTarget(): string | undefined;
|
||||
|
||||
setId(id: string): void;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/ExternalRouteRef',
|
||||
versions: ['v1'],
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export const OpaqueRouteRef = OpaqueType.create<{
|
||||
public: RouteRef;
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
|
||||
getParams(): string[];
|
||||
getDescription(): string;
|
||||
|
||||
alias: string | undefined;
|
||||
|
||||
setId(id: string): void;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/RouteRef',
|
||||
versions: ['v1'],
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef, SubRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueType } from '@internal/opaque';
|
||||
|
||||
export const OpaqueSubRouteRef = OpaqueType.create<{
|
||||
public: SubRouteRef;
|
||||
versions: {
|
||||
readonly version: 'v1';
|
||||
|
||||
getParams(): string[];
|
||||
getParent(): RouteRef;
|
||||
getDescription(): string;
|
||||
};
|
||||
}>({
|
||||
type: '@backstage/SubRouteRef',
|
||||
versions: ['v1'],
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { OpaqueRouteRef } from './OpaqueRouteRef';
|
||||
export { OpaqueSubRouteRef } from './OpaqueSubRouteRef';
|
||||
export { OpaqueExternalRouteRef } from './OpaqueExternalRouteRef';
|
||||
@@ -625,7 +625,7 @@ export function createExternalRouteRef<
|
||||
}
|
||||
| undefined = undefined,
|
||||
TParamKeys extends string = string,
|
||||
>(options?: {
|
||||
>(config?: {
|
||||
readonly params?: string extends TParamKeys
|
||||
? (keyof TParams)[]
|
||||
: TParamKeys[];
|
||||
|
||||
@@ -14,24 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ExternalRouteRef,
|
||||
createExternalRouteRef,
|
||||
toInternalExternalRouteRef,
|
||||
} from './ExternalRouteRef';
|
||||
import { ExternalRouteRef, createExternalRouteRef } from './ExternalRouteRef';
|
||||
import { OpaqueExternalRouteRef } from '@internal/frontend';
|
||||
import { AnyRouteRefParams } from './types';
|
||||
|
||||
describe('ExternalRouteRef', () => {
|
||||
it('should be created', () => {
|
||||
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
const internal = OpaqueExternalRouteRef.toInternal(routeRef);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
|
||||
expect(String(internal)).toMatch(
|
||||
/^ExternalRouteRef\{created at '.*ExternalRouteRef\.test\.ts.*'\}$/,
|
||||
/^externalRouteRef\{id=undefined,at='.*ExternalRouteRef\.test\.ts.*'\}$/,
|
||||
);
|
||||
internal.setId('some-id');
|
||||
expect(String(internal)).toBe('ExternalRouteRef{some-id}');
|
||||
expect(String(internal)).toMatch(
|
||||
/^externalRouteRef\{id=some-id,at='.*ExternalRouteRef\.test\.ts.*'\}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
@@ -39,7 +38,7 @@ describe('ExternalRouteRef', () => {
|
||||
x: string;
|
||||
y: string;
|
||||
}> = createExternalRouteRef({ params: ['x', 'y'] });
|
||||
const internal = toInternalExternalRouteRef(routeRef);
|
||||
const internal = OpaqueExternalRouteRef.toInternal(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRefImpl } from './RouteRef';
|
||||
import { OpaqueExternalRouteRef } from '@internal/frontend';
|
||||
import { describeParentCallSite } from './describeParentCallSite';
|
||||
import { AnyRouteRefParams } from './types';
|
||||
|
||||
@@ -58,37 +58,6 @@ export function toInternalExternalRouteRef<
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isExternalRouteRef(opaque: {
|
||||
$$type: string;
|
||||
}): opaque is ExternalRouteRef {
|
||||
return opaque.$$type === '@backstage/ExternalRouteRef';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
class ExternalRouteRefImpl
|
||||
extends RouteRefImpl
|
||||
implements InternalExternalRouteRef
|
||||
{
|
||||
readonly $$type = '@backstage/ExternalRouteRef' as any;
|
||||
readonly params: string[];
|
||||
readonly defaultTarget: string | undefined;
|
||||
|
||||
constructor(
|
||||
params: string[] = [],
|
||||
defaultTarget: string | undefined,
|
||||
creationSite: string,
|
||||
) {
|
||||
super(params, creationSite);
|
||||
this.params = params;
|
||||
this.defaultTarget = defaultTarget;
|
||||
}
|
||||
|
||||
getDefaultTarget() {
|
||||
return this.defaultTarget;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
|
||||
*
|
||||
@@ -102,7 +71,7 @@ class ExternalRouteRefImpl
|
||||
export function createExternalRouteRef<
|
||||
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
|
||||
TParamKeys extends string = string,
|
||||
>(options?: {
|
||||
>(config?: {
|
||||
/**
|
||||
* The parameters that will be provided to the external route reference.
|
||||
*/
|
||||
@@ -124,9 +93,38 @@ export function createExternalRouteRef<
|
||||
? TParams
|
||||
: { [param in TParamKeys]: string }
|
||||
> {
|
||||
return new ExternalRouteRefImpl(
|
||||
options?.params as string[] | undefined,
|
||||
options?.defaultTarget,
|
||||
describeParentCallSite(),
|
||||
);
|
||||
const params = (config?.params ?? []) as string[];
|
||||
const creationSite = describeParentCallSite();
|
||||
|
||||
let id: string | undefined = undefined;
|
||||
|
||||
return OpaqueExternalRouteRef.createInstance('v1', {
|
||||
T: undefined as unknown as TParams,
|
||||
getParams() {
|
||||
return params;
|
||||
},
|
||||
getDescription() {
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
return `created at '${creationSite}'`;
|
||||
},
|
||||
getDefaultTarget() {
|
||||
return config?.defaultTarget;
|
||||
},
|
||||
setId(newId: string) {
|
||||
if (!newId) {
|
||||
throw new Error(`ExternalRouteRef id must be a non-empty string`);
|
||||
}
|
||||
if (id && id !== newId) {
|
||||
throw new Error(
|
||||
`ExternalRouteRef was referenced twice as both '${id}' and '${newId}'`,
|
||||
);
|
||||
}
|
||||
id = newId;
|
||||
},
|
||||
toString(): string {
|
||||
return `externalRouteRef{id=${id},at='${creationSite}'}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,18 +15,19 @@
|
||||
*/
|
||||
|
||||
import { AnyRouteRefParams } from './types';
|
||||
import { RouteRef, createRouteRef, toInternalRouteRef } from './RouteRef';
|
||||
import { RouteRef, createRouteRef } from './RouteRef';
|
||||
import { OpaqueRouteRef } from '@internal/frontend';
|
||||
|
||||
describe('RouteRef', () => {
|
||||
it('should be created and have a mutable ID', () => {
|
||||
const routeRef: RouteRef<undefined> = createRouteRef();
|
||||
const internal = toInternalRouteRef(routeRef);
|
||||
const internal = OpaqueRouteRef.toInternal(routeRef);
|
||||
expect(internal.T).toBe(undefined);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/);
|
||||
|
||||
expect(String(internal)).toMatch(
|
||||
/^RouteRef\{created at .*RouteRef\.test\.ts.*\}$/,
|
||||
/^routeRef\{id=undefined,at='.*RouteRef\.test\.ts.*'\}$/,
|
||||
);
|
||||
|
||||
expect(() => internal.setId('')).toThrow(
|
||||
@@ -34,7 +35,9 @@ describe('RouteRef', () => {
|
||||
);
|
||||
|
||||
internal.setId('some-id');
|
||||
expect(String(internal)).toBe('RouteRef{some-id}');
|
||||
expect(String(internal)).toMatch(
|
||||
/^routeRef\{id=some-id,at='.*RouteRef\.test\.ts.*'\}$/,
|
||||
);
|
||||
internal.setId('some-id'); // Should allow same ID
|
||||
|
||||
expect(() => internal.setId('some-other-id')).toThrow(
|
||||
@@ -49,7 +52,7 @@ describe('RouteRef', () => {
|
||||
}> = createRouteRef({
|
||||
params: ['x', 'y'],
|
||||
});
|
||||
const internal = toInternalRouteRef(routeRef);
|
||||
const internal = OpaqueRouteRef.toInternal(routeRef);
|
||||
expect(internal.getParams()).toEqual(['x', 'y']);
|
||||
expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OpaqueRouteRef } from '@internal/frontend';
|
||||
import { describeParentCallSite } from './describeParentCallSite';
|
||||
import { AnyRouteRefParams } from './types';
|
||||
|
||||
@@ -33,93 +34,6 @@ export interface RouteRef<
|
||||
readonly T: TParams;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
> extends RouteRef<TParams> {
|
||||
readonly version: 'v1';
|
||||
getParams(): string[];
|
||||
getDescription(): string;
|
||||
|
||||
alias: string | undefined;
|
||||
|
||||
setId(id: string): void;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
>(resource: RouteRef<TParams>): InternalRouteRef<TParams> {
|
||||
const r = resource as InternalRouteRef<TParams>;
|
||||
if (r.$$type !== '@backstage/RouteRef') {
|
||||
throw new Error(`Invalid RouteRef, bad type '${r.$$type}'`);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isRouteRef(opaque: { $$type: string }): opaque is RouteRef {
|
||||
return opaque.$$type === '@backstage/RouteRef';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class RouteRefImpl implements InternalRouteRef {
|
||||
readonly $$type = '@backstage/RouteRef';
|
||||
readonly version = 'v1';
|
||||
declare readonly T: never;
|
||||
|
||||
#id?: string;
|
||||
readonly #params: string[];
|
||||
readonly #creationSite: string;
|
||||
readonly #alias?: string;
|
||||
|
||||
constructor(
|
||||
readonly params: string[] = [],
|
||||
creationSite: string,
|
||||
alias?: string,
|
||||
) {
|
||||
this.#params = params;
|
||||
this.#creationSite = creationSite;
|
||||
this.#alias = alias;
|
||||
}
|
||||
|
||||
getParams(): string[] {
|
||||
return this.#params;
|
||||
}
|
||||
|
||||
get alias(): string | undefined {
|
||||
return this.#alias;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.#id) {
|
||||
return this.#id;
|
||||
}
|
||||
return `created at '${this.#creationSite}'`;
|
||||
}
|
||||
|
||||
get #name() {
|
||||
return this.$$type.slice('@backstage/'.length);
|
||||
}
|
||||
|
||||
setId(id: string): void {
|
||||
if (!id) {
|
||||
throw new Error(`${this.#name} id must be a non-empty string`);
|
||||
}
|
||||
if (this.#id && this.#id !== id) {
|
||||
throw new Error(
|
||||
`${this.#name} was referenced twice as both '${this.#id}' and '${id}'`,
|
||||
);
|
||||
}
|
||||
this.#id = id;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `${this.#name}{${this.getDescription()}}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link RouteRef} from a route descriptor.
|
||||
*
|
||||
@@ -145,9 +59,36 @@ export function createRouteRef<
|
||||
? TParams
|
||||
: { [param in TParamKeys]: string }
|
||||
> {
|
||||
return new RouteRefImpl(
|
||||
config?.params as string[] | undefined,
|
||||
describeParentCallSite(),
|
||||
config?.aliasFor,
|
||||
) as RouteRef<any>;
|
||||
const params = (config?.params ?? []) as string[];
|
||||
const creationSite = describeParentCallSite();
|
||||
|
||||
let id: string | undefined = undefined;
|
||||
|
||||
return OpaqueRouteRef.createInstance('v1', {
|
||||
T: undefined as unknown as TParams,
|
||||
getParams() {
|
||||
return params;
|
||||
},
|
||||
getDescription() {
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
return `created at '${creationSite}'`;
|
||||
},
|
||||
alias: config?.aliasFor,
|
||||
setId(newId: string) {
|
||||
if (!newId) {
|
||||
throw new Error(`RouteRef id must be a non-empty string`);
|
||||
}
|
||||
if (id && id !== newId) {
|
||||
throw new Error(
|
||||
`RouteRef was referenced twice as both '${id}' and '${newId}'`,
|
||||
);
|
||||
}
|
||||
id = newId;
|
||||
},
|
||||
toString(): string {
|
||||
return `routeRef{id=${id},at='${creationSite}'}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,33 +15,32 @@
|
||||
*/
|
||||
|
||||
import { AnyRouteRefParams } from './types';
|
||||
import {
|
||||
SubRouteRef,
|
||||
createSubRouteRef,
|
||||
toInternalSubRouteRef,
|
||||
} from './SubRouteRef';
|
||||
import { createRouteRef, toInternalRouteRef } from './RouteRef';
|
||||
import { SubRouteRef, createSubRouteRef } from './SubRouteRef';
|
||||
import { createRouteRef } from './RouteRef';
|
||||
import { OpaqueRouteRef, OpaqueSubRouteRef } from '@internal/frontend';
|
||||
|
||||
const parent = createRouteRef();
|
||||
const parentX = createRouteRef({ params: ['x'] });
|
||||
|
||||
describe('SubRouteRef', () => {
|
||||
it('should be created', () => {
|
||||
const internalParent = toInternalRouteRef(createRouteRef());
|
||||
const internalParent = OpaqueRouteRef.toInternal(createRouteRef());
|
||||
const routeRef: SubRouteRef = createSubRouteRef({
|
||||
parent: internalParent,
|
||||
path: '/foo',
|
||||
});
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
const internal = OpaqueSubRouteRef.toInternal(routeRef);
|
||||
expect(internal.path).toBe('/foo');
|
||||
expect(internal.T).toBe(undefined);
|
||||
expect(internal.getParent()).toBe(internalParent);
|
||||
expect(internal.getParams()).toEqual([]);
|
||||
expect(String(internal)).toMatch(
|
||||
/SubRouteRef\{at \/foo with parent created at '.*SubRouteRef\.test\.ts.*'\}/,
|
||||
/^subRouteRef\{path='\/foo',parent=routeRef\{id=undefined,at='.*SubRouteRef\.test\.ts.*'\}\}$/,
|
||||
);
|
||||
internalParent.setId('some-id');
|
||||
expect(String(internal)).toBe('SubRouteRef{at /foo with parent some-id}');
|
||||
expect(String(internal)).toMatch(
|
||||
/^subRouteRef\{path='\/foo',parent=routeRef\{id=some-id,at='.*SubRouteRef\.test\.ts.*'\}\}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be created with params', () => {
|
||||
@@ -49,7 +48,7 @@ describe('SubRouteRef', () => {
|
||||
parent,
|
||||
path: '/foo/:bar',
|
||||
});
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
const internal = OpaqueSubRouteRef.toInternal(routeRef);
|
||||
expect(internal.path).toBe('/foo/:bar');
|
||||
expect(internal.getParent()).toBe(parent);
|
||||
expect(internal.getParams()).toEqual(['bar']);
|
||||
@@ -64,7 +63,7 @@ describe('SubRouteRef', () => {
|
||||
parent: parentX,
|
||||
path: '/foo/:y/:z',
|
||||
});
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
const internal = OpaqueSubRouteRef.toInternal(routeRef);
|
||||
expect(internal.path).toBe('/foo/:y/:z');
|
||||
expect(internal.getParent()).toBe(parentX);
|
||||
expect(internal.getParams()).toEqual(['x', 'y', 'z']);
|
||||
@@ -75,7 +74,7 @@ describe('SubRouteRef', () => {
|
||||
parent: parentX,
|
||||
path: '/foo/bar',
|
||||
});
|
||||
const internal = toInternalSubRouteRef(routeRef);
|
||||
const internal = OpaqueSubRouteRef.toInternal(routeRef);
|
||||
expect(internal.path).toBe('/foo/bar');
|
||||
expect(internal.getParent()).toBe(parentX);
|
||||
expect(internal.getParams()).toEqual(['x']);
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRef, toInternalRouteRef } from './RouteRef';
|
||||
import { OpaqueRouteRef, OpaqueSubRouteRef } from '@internal/frontend';
|
||||
import { RouteRef } from './RouteRef';
|
||||
import { AnyRouteRefParams } from './types';
|
||||
|
||||
// Should match the pattern in react-router
|
||||
@@ -50,59 +51,6 @@ export interface InternalSubRouteRef<
|
||||
getDescription(): string;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalSubRouteRef<
|
||||
TParams extends AnyRouteRefParams = AnyRouteRefParams,
|
||||
>(resource: SubRouteRef<TParams>): InternalSubRouteRef<TParams> {
|
||||
const r = resource as InternalSubRouteRef<TParams>;
|
||||
if (r.$$type !== '@backstage/SubRouteRef') {
|
||||
throw new Error(`Invalid SubRouteRef, bad type '${r.$$type}'`);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isSubRouteRef(opaque: {
|
||||
$$type: string;
|
||||
}): opaque is SubRouteRef {
|
||||
return opaque.$$type === '@backstage/SubRouteRef';
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export class SubRouteRefImpl<TParams extends AnyRouteRefParams>
|
||||
implements SubRouteRef<TParams>
|
||||
{
|
||||
readonly $$type = '@backstage/SubRouteRef';
|
||||
readonly version = 'v1';
|
||||
declare readonly T: never;
|
||||
|
||||
#params: string[];
|
||||
#parent: RouteRef;
|
||||
|
||||
constructor(readonly path: string, params: string[], parent: RouteRef) {
|
||||
this.#params = params;
|
||||
this.#parent = parent;
|
||||
}
|
||||
|
||||
getParams(): string[] {
|
||||
return this.#params;
|
||||
}
|
||||
|
||||
getParent(): RouteRef {
|
||||
return this.#parent;
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
const parent = toInternalRouteRef(this.#parent);
|
||||
return `at ${this.path} with parent ${parent.getDescription()}`;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `SubRouteRef{${this.getDescription()}}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in {@link PathParams} type declaration.
|
||||
* @ignore
|
||||
@@ -168,8 +116,9 @@ export function createSubRouteRef<
|
||||
const { path, parent } = config;
|
||||
type Params = PathParams<Path>;
|
||||
|
||||
const internalParent = toInternalRouteRef(parent);
|
||||
const internalParent = OpaqueRouteRef.toInternal(parent);
|
||||
const parentParams = internalParent.getParams();
|
||||
const parentDescription = internalParent.getDescription();
|
||||
|
||||
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
|
||||
const pathParams = path
|
||||
@@ -195,14 +144,22 @@ export function createSubRouteRef<
|
||||
}
|
||||
}
|
||||
|
||||
// We ensure that the type of the return type is sane here
|
||||
const subRouteRef = new SubRouteRefImpl(
|
||||
return OpaqueSubRouteRef.createInstance('v1', {
|
||||
T: undefined as unknown as TrimEmptyParams<
|
||||
MergeParams<Params, ParentParams>
|
||||
>,
|
||||
path,
|
||||
params as string[],
|
||||
parent,
|
||||
) as SubRouteRef<TrimEmptyParams<MergeParams<Params, ParentParams>>>;
|
||||
|
||||
// But skip type checking of the return value itself, because the conditional
|
||||
// type checking of the parent parameter overlap is tricky to express.
|
||||
return subRouteRef as any;
|
||||
getParams() {
|
||||
return params;
|
||||
},
|
||||
getParent() {
|
||||
return parent;
|
||||
},
|
||||
getDescription() {
|
||||
return `at ${path} with parent ${parentDescription}`;
|
||||
},
|
||||
toString() {
|
||||
return `subRouteRef{path='${path}',parent=${parent}}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,7 +108,13 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({
|
||||
factory(originalFactory, { config, inputs }) {
|
||||
return originalFactory({
|
||||
path: '/catalog/:namespace/:kind/:name',
|
||||
routeRef: convertLegacyRouteRef(entityRouteRef),
|
||||
// NOTE: The `convertLegacyRouteRef` call here ensures that this route ref
|
||||
// is mutated to support the new frontend system. Removing this conversion
|
||||
// is a potentially breaking change since this is a singleton and the
|
||||
// route refs from `core-plugin-api` used to not support the new format.
|
||||
// This shouldn't be removed until we completely deprecate the
|
||||
// `core-compat-api` package.
|
||||
routeRef: convertLegacyRouteRef(entityRouteRef), // READ THE ABOVE
|
||||
loader: async () => {
|
||||
const { EntityLayout } = await import('./components/EntityLayout');
|
||||
|
||||
|
||||
@@ -3641,6 +3641,7 @@ __metadata:
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
@@ -3653,6 +3654,7 @@ __metadata:
|
||||
react: "npm:^18.0.2"
|
||||
react-dom: "npm:^18.0.2"
|
||||
react-router-dom: "npm:^6.3.0"
|
||||
zod: "npm:^3.22.4"
|
||||
peerDependencies:
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
|
||||
Reference in New Issue
Block a user