Merge pull request #20574 from backstage/rugvip/routerefs

frontend-*-api: reimplement routing system from core-*-api
This commit is contained in:
Patrik Oldsberg
2023-10-17 12:40:11 +02:00
committed by GitHub
74 changed files with 3138 additions and 144 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
Added `RouteRef`, `SubRouteRef`, `ExternalRouteRef`, and related types. All exports from this package that previously relied on the types with the same name from `@backstage/core-plugin-api` now use the new types instead. To convert and existing legacy route ref to be compatible with the APIs from this package, use the `convertLegacyRouteRef` utility from `@backstage/core-plugin-api/alpha`.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-user-settings': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-tech-radar': patch
'@backstage/plugin-graphiql': patch
'@backstage/plugin-search': patch
---
Updated alpha exports according to routing changes in `@backstage/frontend-plugin-api`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': minor
---
Added the ability to configure bound routes through `app.routes.bindings`. The routing system used by `createApp` has been replaced by one that only supports route refs of the new format from `@backstage/frontend-plugin-api`. The requirement for route refs to have the same ID as their associated extension has been removed.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Deprecated several types related to the routing system that are scheduled to be removed, as well as several fields on the route ref types themselves.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
Introduced `AnyRouteRefParams` as a replacement for `AnyParams`, which is now deprecated.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': patch
---
Added a new `/alpha` export `convertLegacyRouteRef`, which is a temporary utility to allow existing route refs to be used with the new experimental packages.
@@ -3,13 +3,11 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { default as React_2 } from 'react';
// @public (undocumented)
const examplePlugin: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const examplePlugin: BackstagePlugin<{}, {}>;
export default examplePlugin;
// @public (undocumented)
+3
View File
@@ -1,6 +1,9 @@
app:
experimental:
packages: 'all' # ✨
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
extensions:
- apis.plugin.graphiql.browse.gitlab: true
+6 -4
View File
@@ -26,6 +26,7 @@ import {
} from '@backstage/frontend-plugin-api';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
/*
@@ -59,7 +60,7 @@ TODO:
const entityPageExtension = createPageExtension({
id: 'catalog:entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: entityRouteRef,
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => <div>Just a temporary mocked entity page</div>,
});
@@ -74,9 +75,10 @@ const app = createApp({
extensions: [entityPageExtension],
}),
],
bindRoutes({ bind }) {
bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX });
},
/* Handled through config instead */
// bindRoutes({ bind }) {
// bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX });
// },
});
// const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] });
@@ -19,18 +19,16 @@ import { Link } from '@backstage/core-components';
import {
createPageExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import {
useRouteRef,
createRouteRef,
createExternalRouteRef,
} from '@backstage/core-plugin-api';
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { Route, Routes } from 'react-router-dom';
const indexRouteRef = createRouteRef({ id: 'index' });
const page1RouteRef = createRouteRef({ id: 'page1' });
export const externalPageXRouteRef = createExternalRouteRef({ id: 'pageX' });
export const pageXRouteRef = createRouteRef({ id: 'pageX' });
const indexRouteRef = createRouteRef();
const page1RouteRef = createRouteRef();
export const externalPageXRouteRef = createExternalRouteRef();
export const pageXRouteRef = createRouteRef();
// const page2RouteRef = createSubRouteRef({
// id: 'page2',
// parent: page1RouteRef,
@@ -3,8 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyRouteRefParams } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/core-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { Observable } from '@backstage/types';
import { RouteRef } from '@backstage/core-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha';
import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha';
@@ -25,6 +29,24 @@ export type AppLanguageApi = {
// @alpha (undocumented)
export const appLanguageApiRef: ApiRef<AppLanguageApi>;
// @public
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: RouteRef<TParams>,
): NewRouteRef<TParams>;
// @public
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: SubRouteRef<TParams>,
): NewSubRouteRef<TParams>;
// @public
export function convertLegacyRouteRef<
TParams extends AnyRouteRefParams,
TOptional extends boolean,
>(
ref: ExternalRouteRef<TParams, TOptional>,
): NewExternalRouteRef<TParams, TOptional>;
// @alpha
export function createTranslationMessages<
TId extends string,
+11 -8
View File
@@ -95,8 +95,11 @@ export type AnyExternalRoutes = {
[name: string]: ExternalRouteRef;
};
// @public @deprecated (undocumented)
export type AnyParams = AnyRouteRefParams;
// @public
export type AnyParams =
export type AnyRouteRefParams =
| {
[param in string]: string;
}
@@ -523,7 +526,7 @@ export type IdentityApi = {
// @public
export const identityApiRef: ApiRef<IdentityApi>;
// @public
// @public @deprecated
export type MakeSubRouteRef<
Params extends {
[param in string]: string;
@@ -533,7 +536,7 @@ export type MakeSubRouteRef<
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
// @public
// @public @deprecated
export type MergeParams<
P1 extends {
[param in string]: string;
@@ -606,30 +609,30 @@ export type OpenIdConnectApi = {
getIdToken(options?: AuthRequestOptions): Promise<string>;
};
// @public
// @public @deprecated
export type OptionalParams<
Params extends {
[param in string]: string;
},
> = Params[keyof Params] extends never ? undefined : Params;
// @public
// @public @deprecated
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
: (keyof Params)[];
// @public
// @public @deprecated
export type ParamNames<S extends string> =
S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
// @public
// @public @deprecated
export type ParamPart<S extends string> = S extends `:${infer Param}`
? Param
: never;
// @public
// @public @deprecated
export type PathParams<S extends string> = {
[name in ParamNames<S>]: string;
};
+1
View File
@@ -16,3 +16,4 @@
export * from './translation';
export * from './apis/alpha';
export { convertLegacyRouteRef } from './routing/convertLegacyRouteRef';
@@ -51,6 +51,7 @@ export class SubRouteRefImpl<Params extends AnyParams>
/**
* Used in {@link PathParams} type declaration.
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type ParamPart<S extends string> = S extends `:${infer Param}`
? Param
@@ -59,6 +60,7 @@ export type ParamPart<S extends string> = S extends `:${infer Param}`
/**
* Used in {@link PathParams} type declaration.
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type ParamNames<S extends string> =
S extends `${infer Part}/${infer Rest}`
@@ -68,12 +70,14 @@ export type ParamNames<S extends string> =
* This utility type helps us infer a Param object type from a string path
* For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with an optional params type into a params object.
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type MergeParams<
P1 extends { [param in string]: string },
@@ -85,6 +89,7 @@ export type MergeParams<
* The parameters types are merged together while ensuring that there is no overlap between the two.
*
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type MakeSubRouteRef<
Params extends { [param in string]: string },
@@ -0,0 +1,188 @@
/*
* Copyright 2023 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 { routeRefType } from './types';
import {
RouteRef as LegacyRouteRef,
SubRouteRef as LegacySubRouteRef,
ExternalRouteRef as LegacyExternalRouteRef,
AnyRouteRefParams,
} from '@backstage/core-plugin-api';
// Relative imports to avoid dependency, at least for now
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
createRouteRef,
createSubRouteRef,
createExternalRouteRef,
} from '../../../frontend-plugin-api/src/routing';
// 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';
// TODO(Rugvip): Once this is moved to a compat package these aliases can be removed and imported from frontend- instead
/** @ignore */
type NewRouteRef<TParams extends AnyRouteRefParams = AnyRouteRefParams> =
RouteRef<TParams>;
/** @ignore */
type NewSubRouteRef<TParams extends AnyRouteRefParams = AnyRouteRefParams> =
SubRouteRef<TParams>;
/** @ignore */
type NewExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
TOptional extends boolean = boolean,
> = ExternalRouteRef<TParams, TOptional>;
/**
* A temporary helper to convert a legacy route ref to the new system.
*
* @public
* @remarks
*
* In the future the legacy createRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: LegacyRouteRef<TParams>,
): NewRouteRef<TParams>;
/**
* A temporary helper to convert a legacy sub route ref to the new system.
*
* @public
* @remarks
*
* In the future the legacy createSubRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<TParams extends AnyRouteRefParams>(
ref: LegacySubRouteRef<TParams>,
): NewSubRouteRef<TParams>;
/**
* A temporary helper to convert a legacy external route ref to the new system.
*
* @public
* @remarks
*
* In the future the legacy createExternalRouteRef will instead create refs compatible with both systems.
*/
export function convertLegacyRouteRef<
TParams extends AnyRouteRefParams,
TOptional extends boolean,
>(
ref: LegacyExternalRouteRef<TParams, TOptional>,
): NewExternalRouteRef<TParams, TOptional>;
export function convertLegacyRouteRef(
ref: LegacyRouteRef | LegacySubRouteRef | LegacyExternalRouteRef,
): NewRouteRef | NewSubRouteRef | NewExternalRouteRef {
// Ref has already been converted
if ('$$type' in ref) {
return ref as unknown as NewRouteRef | NewSubRouteRef | NewExternalRouteRef;
}
const type = (ref as unknown as { [routeRefType]: unknown })[routeRefType];
if (type === 'absolute') {
const legacyRef = ref as LegacyRouteRef;
const newRef = toInternalRouteRef(
createRouteRef<{ [key in string]: string }>({
params: legacyRef.params as string[],
}),
);
return Object.assign(legacyRef, {
$$type: '@backstage/RouteRef' as const,
version: 'v1',
T: newRef.T,
getParams() {
return newRef.getParams();
},
getDescription() {
return newRef.getDescription();
},
setId(id: string) {
newRef.setId(id);
},
toString() {
return newRef.toString();
},
});
}
if (type === 'sub') {
const legacyRef = ref as LegacySubRouteRef;
const newRef = toInternalSubRouteRef(
createSubRouteRef({
path: legacyRef.path,
parent: convertLegacyRouteRef(legacyRef.parent),
}),
);
return Object.assign(legacyRef, {
$$type: '@backstage/SubRouteRef' as const,
version: 'v1',
T: newRef.T,
getParams() {
return newRef.getParams();
},
getParent() {
return newRef.getParent();
},
getDescription() {
return newRef.getDescription();
},
toString() {
return newRef.toString();
},
});
}
if (type === 'external') {
const legacyRef = ref as LegacyExternalRouteRef;
const newRef = toInternalExternalRouteRef(
createExternalRouteRef<{ [key in string]: string }>({
params: legacyRef.params as string[],
optional: legacyRef.optional,
}),
);
return Object.assign(legacyRef, {
$$type: '@backstage/ExternalRouteRef' as const,
version: 'v1',
T: newRef.T,
optional: newRef.optional,
getParams() {
return newRef.getParams();
},
getDescription() {
return newRef.getDescription();
},
setId(id: string) {
newRef.setId(id);
},
toString() {
return newRef.toString();
},
});
}
throw new Error(`Failed to convert legacy route ref, unknown type '${type}'`);
}
@@ -16,6 +16,7 @@
export type {
AnyParams,
AnyRouteRefParams,
RouteRef,
SubRouteRef,
ExternalRouteRef,
+16 -1
View File
@@ -21,12 +21,19 @@ import { getOrCreateGlobalSingleton } from '@backstage/version-bridge';
*
* @public
*/
export type AnyParams = { [param in string]: string } | undefined;
export type AnyRouteRefParams = { [param in string]: string } | undefined;
/**
* @deprecated use {@link AnyRouteRefParams} instead
* @public
*/
export type AnyParams = AnyRouteRefParams;
/**
* Type describing the key type of a route parameter mapping.
*
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
@@ -36,6 +43,7 @@ export type ParamKeys<Params extends AnyParams> = keyof Params extends never
* Optional route params.
*
* @public
* @deprecated this type is deprecated and will be removed in the future
*/
export type OptionalParams<Params extends { [param in string]: string }> =
Params[keyof Params] extends never ? undefined : Params;
@@ -81,8 +89,10 @@ export const routeRefType: unique symbol = getOrCreateGlobalSingleton<any>(
* @public
*/
export type RouteRef<Params extends AnyParams = any> = {
/** @deprecated access to this property will be removed in the future */
$$routeRefType: 'absolute'; // See routeRefType above
/** @deprecated access to this property will be removed in the future */
params: ParamKeys<Params>;
};
@@ -96,12 +106,15 @@ export type RouteRef<Params extends AnyParams = any> = {
* @public
*/
export type SubRouteRef<Params extends AnyParams = any> = {
/** @deprecated access to this property will be removed in the future */
$$routeRefType: 'sub'; // See routeRefType above
/** @deprecated access to this property will be removed in the future */
parent: RouteRef;
path: string;
/** @deprecated access to this property will be removed in the future */
params: ParamKeys<Params>;
};
@@ -118,8 +131,10 @@ export type ExternalRouteRef<
Params extends AnyParams = any,
Optional extends boolean = any,
> = {
/** @deprecated access to this property will be removed in the future */
$$routeRefType: 'external'; // See routeRefType above
/** @deprecated access to this property will be removed in the future */
params: ParamKeys<Params>;
optional?: Optional;
+16 -1
View File
@@ -3,13 +3,28 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AppRouteBinder } from '@backstage/core-app-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionOverrides } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
// @public
export type AppRouteBinder = <
TExternalRoutes extends {
[name: string]: ExternalRouteRef;
},
>(
externalRoutes: TExternalRoutes,
targetRoutes: PartialKeys<
TargetRouteMap<TExternalRoutes>,
KeysWithType<TExternalRoutes, ExternalRouteRef<any, true>>
>,
) => void;
// @public (undocumented)
export function createApp(options: {
+7
View File
@@ -24,6 +24,13 @@ export interface Config {
packages?: 'all' | { include?: string[]; exclude?: string[] };
};
routes?: {
/**
* @deepVisibility frontend
*/
bindings?: { [externalRouteRefId: string]: string };
};
/**
* @deepVisibility frontend
*/
@@ -20,8 +20,8 @@ import {
coreExtensionData,
createExtensionInput,
NavTarget,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { useRouteRef } from '@backstage/core-plugin-api';
import { makeStyles } from '@material-ui/core';
import {
Sidebar,
@@ -66,7 +66,7 @@ const SidebarLogo = () => {
const SidebarNavItem = (props: NavTarget) => {
const { icon: Icon, title, routeRef } = props;
const to = useRouteRef(routeRef)({});
const to = useRouteRef(routeRef)();
// TODO: Support opening modal, for example, the search one
return <SidebarItem to={to} icon={Icon} text={title} />;
};
+1
View File
@@ -21,3 +21,4 @@
*/
export * from './wiring';
export * from './routing';
@@ -0,0 +1,366 @@
/*
* Copyright 2020 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 {
createRouteRef,
createSubRouteRef,
createExternalRouteRef,
ExternalRouteRef,
RouteRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteResolver } from './RouteResolver';
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromInstanceTree';
const element = () => null;
const rest = {
element,
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set<BackstagePlugin>(),
};
const ref1 = createRouteRef();
const ref2 = createRouteRef({ params: ['x'] });
const ref3 = createRouteRef({ params: ['y'] });
const subRef1 = createSubRouteRef({ parent: ref1, path: '/foo' });
const subRef2 = createSubRouteRef({ parent: ref1, path: '/foo/:a' });
const subRef3 = createSubRouteRef({ parent: ref2, path: '/bar' });
const subRef4 = createSubRouteRef({ parent: ref2, path: '/bar/:a' });
const externalRef1 = createExternalRouteRef();
const externalRef2 = createExternalRouteRef({ optional: true });
const externalRef3 = createExternalRouteRef({ params: ['x'] });
const externalRef4 = createExternalRouteRef({ optional: true, params: ['x'] });
describe('RouteResolver', () => {
it('should not resolve anything with an empty resolver', () => {
const r = new RouteResolver(new Map(), new Map(), [], new Map(), '');
expect(r.resolve(ref1, '/')?.()).toBe(undefined);
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
expect(r.resolve(subRef1, '/')?.()).toBe(undefined);
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe(undefined);
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined);
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined);
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
});
it('should resolve an absolute route', () => {
const r = new RouteResolver(
new Map([[ref1, 'my-route']]),
new Map(),
[{ routeRefs: new Set([ref1]), path: 'my-route', ...rest }],
new Map(),
'',
);
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe(undefined);
expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo');
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a');
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(undefined);
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(undefined);
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
});
it('should resolve an absolute route and sub route with an app base path', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref2, 'my-parent/:x'],
[ref1, 'my-route'],
]),
new Map<RouteRef, RouteRef>([[ref1, ref2]]),
[
{
routeRefs: new Set([ref2]),
path: 'my-parent/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
],
},
],
new Map(),
'/base',
);
expect(r.resolve(ref1, '/my-parent/1x')?.()).toBe(
'/base/my-parent/1x/my-route',
);
expect(r.resolve(ref1, '/base/my-parent/1x')?.()).toBe(
'/base/my-parent/1x/my-route',
);
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/base/my-parent/1x');
expect(r.resolve(ref2, '/base')?.({ x: '1x' })).toBe('/base/my-parent/1x');
expect(r.resolve(ref3, '/')?.({ y: '1y' })).toBe(undefined);
expect(r.resolve(subRef1, '/my-parent/2x')?.()).toBe(
'/base/my-parent/2x/my-route/foo',
);
expect(r.resolve(subRef1, '/base/my-parent/2x')?.()).toBe(
'/base/my-parent/2x/my-route/foo',
);
expect(r.resolve(subRef2, '/my-parent/3x')?.({ a: '2a' })).toBe(
'/base/my-parent/3x/my-route/foo/2a',
);
expect(r.resolve(subRef2, '/base/my-parent/3x')?.({ a: '2a' })).toBe(
'/base/my-parent/3x/my-route/foo/2a',
);
expect(r.resolve(subRef3, '/')?.({ x: '5x' })).toBe(
'/base/my-parent/5x/bar',
);
expect(r.resolve(subRef4, '/')?.({ x: '6x', a: '4a' })).toBe(
'/base/my-parent/6x/bar/4a',
);
expect(r.resolve(externalRef1, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(undefined);
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(undefined);
});
it('should resolve an absolute route with a param and with a parent', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref1, 'my-route'],
[ref2, 'my-parent/:x'],
]),
new Map([[ref2, ref1]]),
[
{
routeRefs: new Set([ref2]),
path: 'my-parent/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
[externalRef1, ref1],
[externalRef3, ref2],
[externalRef4, subRef3],
]),
'',
);
expect(r.resolve(ref1, '/')?.()).toBe('/my-route');
expect(r.resolve(ref2, '/')?.({ x: '1x' })).toBe('/my-route/my-parent/1x');
expect(r.resolve(subRef1, '/')?.()).toBe('/my-route/foo');
expect(r.resolve(subRef2, '/')?.({ a: '2a' })).toBe('/my-route/foo/2a');
expect(r.resolve(subRef3, '/')?.({ x: '3x' })).toBe(
'/my-route/my-parent/3x/bar',
);
expect(r.resolve(subRef4, '/')?.({ x: '4x', a: '4a' })).toBe(
'/my-route/my-parent/4x/bar/4a',
);
expect(r.resolve(externalRef1, '/')?.()).toBe('/my-route');
expect(r.resolve(externalRef2, '/')?.()).toBe(undefined);
expect(r.resolve(externalRef3, '/')?.({ x: '5x' })).toBe(
'/my-route/my-parent/5x',
);
expect(r.resolve(externalRef4, '/')?.({ x: '6x' })).toBe(
'/my-route/my-parent/6x/bar',
);
});
it('should resolve the most specific match', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref1, 'deep'],
[ref2, 'root/:x'],
[ref3, 'sub/:y'],
]),
new Map<RouteRef, RouteRef>([
[ref3, ref2],
[ref1, ref3],
]),
[
{
routeRefs: new Set([ref2]),
path: 'root/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{
routeRefs: new Set([ref3]),
path: 'sub/:y',
...rest,
children: [
MATCH_ALL_ROUTE,
{
routeRefs: new Set([ref1]),
path: 'deep',
...rest,
},
],
},
],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>(),
'',
);
expect(r.resolve(ref2, '/')?.({ x: 'x' })).toBe('/root/x');
expect(r.resolve(ref3, '/root/x')?.({ y: 'y' })).toBe('/root/x/sub/y');
expect(() => r.resolve(ref1, '/')?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(() => r.resolve(ref1, '/root/x')?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(ref1, '/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(ref1, '/root/x/sub/y/any/nested/path/here')?.()).toBe(
'/root/x/sub/y/deep',
);
});
it('should resolve an absolute route with multiple parents', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref1, 'my-route'],
[ref2, 'my-parent/:x'],
[ref3, 'my-grandparent/:y'],
]),
new Map<RouteRef, RouteRef>([
[ref1, ref2],
[ref2, ref3],
]),
[
{
routeRefs: new Set([ref3]),
path: 'my-grandparent/:y',
...rest,
children: [
MATCH_ALL_ROUTE,
{
routeRefs: new Set([ref2]),
path: 'my-parent/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
],
},
],
},
],
new Map<ExternalRouteRef, RouteRef | SubRouteRef>([
[externalRef1, ref1],
[externalRef3, ref2],
[externalRef4, subRef3],
]),
'',
);
const l = '/my-grandparent/my-y/my-parent/my-x';
expect(r.resolve(ref1, l)?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route',
);
expect(() => r.resolve(ref1, '/')?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(ref2, l)?.({ x: '1x' })).toBe(
'/my-grandparent/my-y/my-parent/1x',
);
expect(r.resolve(ref2, '/my-grandparent/my-y')?.({ x: '1x' })).toBe(
'/my-grandparent/my-y/my-parent/1x',
);
expect(() => r.resolve(ref2, '/')?.({ x: '1x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(subRef1, l)?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route/foo',
);
expect(() => r.resolve(subRef1, '/')?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(subRef2, l)?.({ a: '2a' })).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route/foo/2a',
);
expect(() => r.resolve(subRef2, '/')?.({ a: '2a' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(subRef3, l)?.({ x: '3x' })).toBe(
'/my-grandparent/my-y/my-parent/3x/bar',
);
expect(r.resolve(subRef3, '/my-grandparent/my-y')?.({ x: '3x' })).toBe(
'/my-grandparent/my-y/my-parent/3x/bar',
);
expect(r.resolve(subRef4, l)?.({ x: '4x', a: '4a' })).toBe(
'/my-grandparent/my-y/my-parent/4x/bar/4a',
);
expect(
r.resolve(subRef4, '/my-grandparent/my-y')?.({ x: '4x', a: '4a' }),
).toBe('/my-grandparent/my-y/my-parent/4x/bar/4a');
expect(r.resolve(externalRef1, l)?.()).toBe(
'/my-grandparent/my-y/my-parent/my-x/my-route',
);
expect(() => r.resolve(externalRef1, '/')?.()).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(externalRef2, l)?.()).toBe(undefined);
expect(r.resolve(externalRef3, l)?.({ x: '5x' })).toBe(
'/my-grandparent/my-y/my-parent/5x',
);
expect(() => r.resolve(externalRef3, '/')?.({ x: '5x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
expect(r.resolve(externalRef4, l)?.({ x: '6x' })).toBe(
'/my-grandparent/my-y/my-parent/6x/bar',
);
expect(() => r.resolve(externalRef4, '/')?.({ x: '6x' })).toThrow(
/^Cannot route.*with parent.*as it has parameters$/,
);
});
it('should encode some characters in params', () => {
const r = new RouteResolver(
new Map<RouteRef, string>([
[ref2, 'my-parent/:x'],
[ref1, 'my-route'],
]),
new Map<RouteRef, RouteRef>([[ref1, ref2]]),
[
{
routeRefs: new Set([ref2]),
path: 'my-parent/:x',
...rest,
children: [
MATCH_ALL_ROUTE,
{ routeRefs: new Set([ref1]), path: 'my-route', ...rest },
],
},
],
new Map(),
'/base',
);
expect(r.resolve(ref2, '/')?.({ x: 'a/#&?b' })).toBe(
'/base/my-parent/a%2F%23%26%3Fb',
);
});
});
@@ -0,0 +1,268 @@
/*
* Copyright 2020 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 { generatePath, matchRoutes } from 'react-router-dom';
import {
RouteRef,
ExternalRouteRef,
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
} 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 } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
// Joins a list of paths together, avoiding trailing and duplicate slashes
export function joinPaths(...paths: string[]): string {
const normalized = paths.join('/').replace(/\/\/+/g, '/');
if (normalized !== '/' && normalized.endsWith('/')) {
return normalized.slice(0, -1);
}
return normalized;
}
/**
* Resolves the absolute route ref that our target route ref is pointing pointing to, as well
* as the relative target path.
*
* Returns an undefined target ref if one could not be fully resolved.
*/
function resolveTargetRef(
anyRouteRef: AnyRouteRef,
routePaths: Map<RouteRef, string>,
routeBindings: Map<AnyRouteRef, AnyRouteRef | undefined>,
): readonly [RouteRef | undefined, string] {
// First we figure out which absolute route ref we're dealing with, an if there was an sub route path to append.
// For sub routes it will be the parent path, while for external routes it will be the bound route.
let targetRef: RouteRef;
let subRoutePath = '';
if (isRouteRef(anyRouteRef)) {
targetRef = anyRouteRef;
} else if (isSubRouteRef(anyRouteRef)) {
const internal = toInternalSubRouteRef(anyRouteRef);
targetRef = internal.getParent();
subRoutePath = internal.path;
} else if (isExternalRouteRef(anyRouteRef)) {
const resolvedRoute = routeBindings.get(anyRouteRef);
if (!resolvedRoute) {
return [undefined, ''];
}
if (isRouteRef(resolvedRoute)) {
targetRef = resolvedRoute;
} else if (isSubRouteRef(resolvedRoute)) {
const internal = toInternalSubRouteRef(resolvedRoute);
targetRef = internal.getParent();
subRoutePath = resolvedRoute.path;
} else {
throw new Error(
`ExternalRouteRef was bound to invalid target, ${resolvedRoute}`,
);
}
} else {
throw new Error(`Unknown object passed to useRouteRef, got ${anyRouteRef}`);
}
// Bail if no absolute path could be resolved
if (!targetRef) {
return [undefined, ''];
}
// Find the path that our target route is bound to
const resolvedPath = routePaths.get(targetRef);
if (resolvedPath === undefined) {
return [undefined, ''];
}
// SubRouteRefs join the path from the parent route with its own path
const targetPath = joinPaths(resolvedPath, subRoutePath);
return [targetRef, targetPath];
}
/**
* Resolves the complete base path for navigating to the target RouteRef.
*/
function resolveBasePath(
targetRef: RouteRef,
sourceLocation: Parameters<typeof matchRoutes>[1],
routePaths: Map<RouteRef, string>,
routeParents: Map<RouteRef, RouteRef | undefined>,
routeObjects: BackstageRouteObject[],
) {
// While traversing the app element tree we build up the routeObjects structure
// used here. It is the same kind of structure that react-router creates, with the
// addition that associated route refs are stored throughout the tree. This lets
// us look up all route refs that can be reached from our source location.
// Because of the similar route object structure, we can use `matchRoutes` from
// react-router to do the lookup of our current location.
const match = matchRoutes(routeObjects, sourceLocation) ?? [];
// While we search for a common routing root between our current location and
// the target route, we build a list of all route refs we find that we need
// to traverse to reach the target.
const refDiffList = Array<RouteRef>();
let matchIndex = -1;
for (
let targetSearchRef: RouteRef | undefined = targetRef;
targetSearchRef;
targetSearchRef = routeParents.get(targetSearchRef)
) {
// The match contains a list of all ancestral route refs present at our current location
// Starting at the desired target ref and traversing back through its parents, we search
// for a target ref that is present in the match for our current location. When a match
// is found it means we have found a common base to resolve the route from.
matchIndex = match.findIndex(m =>
(m.route as BackstageRouteObject).routeRefs.has(targetSearchRef!),
);
if (matchIndex !== -1) {
break;
}
// Every time we move a step up in the ancestry of the target ref, we add the current ref
// to the diff list, which ends up being the list of route refs to traverse form the common base
// in order to reach our target.
refDiffList.unshift(targetSearchRef);
}
// If our target route is present in the initial match we need to construct the final path
// from the parent of the matched route segment. That's to allow the caller of the route
// function to supply their own params.
if (refDiffList.length === 0) {
matchIndex -= 1;
}
// This is the part of the route tree that the target and source locations have in common.
// We re-use the existing pathname directly along with all params.
const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname;
// This constructs the mid section of the path using paths resolved from all route refs
// we need to traverse to reach our target except for the very last one. None of these
// paths are allowed to require any parameters, as the caller would have no way of knowing
// what parameters those are.
const diffPaths = refDiffList.slice(0, -1).map(ref => {
const path = routePaths.get(ref);
if (path === undefined) {
throw new Error(`No path for ${ref}`);
}
if (path.includes(':')) {
throw new Error(
`Cannot route to ${targetRef} with parent ${ref} as it has parameters`,
);
}
return path;
});
return `${joinPaths(parentPath, ...diffPaths)}/`;
}
export class RouteResolver {
constructor(
private readonly routePaths: Map<RouteRef, string>,
private readonly routeParents: Map<RouteRef, RouteRef | undefined>,
private readonly routeObjects: BackstageRouteObject[],
private readonly routeBindings: Map<
ExternalRouteRef,
RouteRef | SubRouteRef
>,
private readonly appBasePath: string, // base path without a trailing slash
) {}
resolve<Params extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
sourceLocation: Parameters<typeof matchRoutes>[1],
): RouteFunc<Params> | undefined {
// First figure out what our target absolute ref is, as well as our target path.
const [targetRef, targetPath] = resolveTargetRef(
anyRouteRef,
this.routePaths,
this.routeBindings,
);
if (!targetRef) {
return undefined;
}
// The location that we get passed in uses the full path, so start by trimming off
// the app base path prefix in case we're running the app on a sub-path.
let relativeSourceLocation: Parameters<typeof matchRoutes>[1];
if (typeof sourceLocation === 'string') {
relativeSourceLocation = this.trimPath(sourceLocation);
} else if (sourceLocation.pathname) {
relativeSourceLocation = {
...sourceLocation,
pathname: this.trimPath(sourceLocation.pathname),
};
} else {
relativeSourceLocation = sourceLocation;
}
// Next we figure out the base path, which is the combination of the common parent path
// between our current location and our target location, as well as the additional path
// that is the difference between the parent path and the base of our target location.
const basePath =
this.appBasePath +
resolveBasePath(
targetRef,
relativeSourceLocation,
this.routePaths,
this.routeParents,
this.routeObjects,
);
const routeFunc: RouteFunc<Params> = (...[params]) => {
// We selectively encode some some known-dangerous characters in the
// params. The reason that we don't perform a blanket `encodeURIComponent`
// here is that this encoding was added defensively long after the initial
// release of this code. There's likely to be many users of this code that
// already encode their parameters knowing that this code didn't do this
// for them in the past. Therefore, we are extra careful NOT to include
// the percent character in this set, even though that might seem like a
// bad idea.
const encodedParams =
params &&
mapValues(params, value => {
if (typeof value === 'string') {
return value.replaceAll(/[&?#;\/]/g, c => encodeURIComponent(c));
}
return value;
});
return joinPaths(basePath, generatePath(targetPath, encodedParams));
};
return routeFunc;
}
private trimPath(targetPath: string) {
if (!targetPath) {
return targetPath;
}
if (targetPath.startsWith(this.appBasePath)) {
return targetPath.slice(this.appBasePath.length);
}
return targetPath;
}
}
@@ -0,0 +1,66 @@
/*
* Copyright 2020 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 React, { ReactNode } from 'react';
import {
ExternalRouteRef,
RouteRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import {
createVersionedValueMap,
createVersionedContext,
} from '@backstage/version-bridge';
import { RouteResolver } from './RouteResolver';
import { BackstageRouteObject } from './types';
const RoutingContext = createVersionedContext<{ 1: RouteResolver }>(
'routing-context',
);
type ProviderProps = {
routePaths: Map<RouteRef, string>;
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
basePath?: string;
children: ReactNode;
};
// TODO(Rugvip): Migrate to a routing API instead
export const RoutingProvider = ({
routePaths,
routeParents,
routeObjects,
routeBindings,
basePath = '',
children,
}: ProviderProps) => {
const resolver = new RouteResolver(
routePaths,
routeParents,
routeObjects,
routeBindings,
basePath,
);
const versionedValue = createVersionedValueMap({ 1: resolver });
return (
<RoutingContext.Provider value={versionedValue}>
{children}
</RoutingContext.Provider>
);
};
@@ -0,0 +1,51 @@
/*
* Copyright 2023 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 {
createRouteRef,
createExternalRouteRef,
createPlugin,
} from '@backstage/frontend-plugin-api';
import { collectRouteIds } from './collectRouteIds';
describe('collectRouteIds', () => {
it('should assign IDs to routes', () => {
const ref = createRouteRef();
const extRef = createExternalRouteRef();
expect(String(ref)).toMatch(
/^RouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
);
expect(String(extRef)).toMatch(
/^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
);
const collected = collectRouteIds([
createPlugin({ id: 'test', routes: { ref }, externalRoutes: { extRef } }),
]);
expect(Object.fromEntries(collected.routes)).toEqual({
'plugin.test.routes.ref': ref,
});
expect(Object.fromEntries(collected.externalRoutes)).toEqual({
'plugin.test.externalRoutes.extRef': extRef,
});
expect(String(ref)).toBe('RouteRef{plugin.test.routes.ref}');
expect(String(extRef)).toBe(
'ExternalRouteRef{plugin.test.externalRoutes.extRef}',
);
});
});
@@ -0,0 +1,70 @@
/*
* Copyright 2023 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 {
BackstagePlugin,
ExtensionOverrides,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} 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 { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/routing/ExternalRouteRef';
/** @internal */
export interface RouteRefsById {
routes: Map<string, RouteRef | SubRouteRef>;
externalRoutes: Map<string, ExternalRouteRef>;
}
/** @internal */
export function collectRouteIds(
features: (BackstagePlugin | ExtensionOverrides)[],
): RouteRefsById {
const routesById = new Map<string, RouteRef | SubRouteRef>();
const externalRoutesById = new Map<string, ExternalRouteRef>();
for (const feature of features) {
if (feature.$$type !== '@backstage/BackstagePlugin') {
continue;
}
for (const [name, ref] of Object.entries(feature.routes)) {
const refId = `plugin.${feature.id}.routes.${name}`;
if (routesById.has(refId)) {
throw new Error(`Unexpected duplicate route '${refId}'`);
}
const internalRef = toInternalRouteRef(ref);
internalRef.setId(refId);
routesById.set(refId, ref);
}
for (const [name, ref] of Object.entries(feature.externalRoutes)) {
const refId = `plugin.${feature.id}.externalRoutes.${name}`;
if (externalRoutesById.has(refId)) {
throw new Error(`Unexpected duplicate external route '${refId}'`);
}
const internalRef = toInternalExternalRouteRef(ref);
internalRef.setId(refId);
externalRoutesById.set(refId, ref);
}
}
return { routes: routesById, externalRoutes: externalRoutesById };
}
@@ -15,28 +15,27 @@
*/
import React from 'react';
import {
BackstagePlugin,
RouteRef,
createRouteRef,
} from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { extractRouteInfoFromInstanceTree } from './extractRouteInfoFromInstanceTree';
import {
AnyRouteRefParams,
Extension,
RouteRef,
coreExtensionData,
createExtension,
createExtensionInput,
createPlugin,
createRouteRef,
} from '@backstage/frontend-plugin-api';
import { createInstances } from '../wiring/createApp';
import { MockConfigApi } from '@backstage/test-utils';
const ref1 = createRouteRef({ id: 'page1' });
const ref2 = createRouteRef({ id: 'page2' });
const ref3 = createRouteRef({ id: 'page3' });
const ref4 = createRouteRef({ id: 'page4' });
const ref5 = createRouteRef({ id: 'page5' });
const refOrder = [ref1, ref2, ref3, ref4, ref5];
const ref1 = createRouteRef();
const ref2 = createRouteRef();
const ref3 = createRouteRef();
const ref4 = createRouteRef();
const ref5 = createRouteRef();
const refOrder: RouteRef<AnyRouteRefParams>[] = [ref1, ref2, ref3, ref4, ref5];
function createTestExtension(options: {
id: string;
@@ -14,12 +14,11 @@
* limitations under the License.
*/
import { RouteRef } from '@backstage/core-plugin-api';
import { coreExtensionData } from '@backstage/frontend-plugin-api';
import { RouteRef, coreExtensionData } from '@backstage/frontend-plugin-api';
import { ExtensionInstance } from '../wiring/createExtensionInstance';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { BackstageRouteObject } from '../../../core-app-api/src/routing/types';
import { toLegacyPlugin } from '../wiring/createApp';
import { BackstageRouteObject } from './types';
// We always add a child that matches all subroutes but without any route refs. This makes
// sure that we're always able to match each route no matter how deep the navigation goes.
@@ -109,13 +108,6 @@ export function extractRouteInfoFromInstanceTree(core: ExtensionInstance): {
// Whenever a route ref is encountered, we need to give it a route path and position in the ref tree.
if (routeRef) {
const routeRefId = (routeRef as any).id; // TODO: properly
if (routeRefId !== current.id) {
throw new Error(
`Route ref '${routeRefId}' must have the same ID as extension '${current.id}'`,
);
}
// The first route ref we find after encountering a route path is selected to be used as the
// parent ref further down the tree. We don't start using this candidate ref until we encounter
// another route path though, at which point we repeat the process and select another candidate.
@@ -0,0 +1,17 @@
/*
* Copyright 2023 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 { type AppRouteBinder } from './resolveRouteBindings';
@@ -0,0 +1,120 @@
/*
* Copyright 2020 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 {
createExternalRouteRef,
createRouteRef,
} from '@backstage/frontend-plugin-api';
import { resolveRouteBindings } from './resolveRouteBindings';
import { ConfigReader } from '@backstage/config';
const emptyIds = { routes: new Map(), externalRoutes: new Map() };
describe('resolveRouteBindings', () => {
it('runs happy path', () => {
const external = { myRoute: createExternalRouteRef() };
const ref = createRouteRef();
const result = resolveRouteBindings(
({ bind }) => {
bind(external, { myRoute: ref });
},
new ConfigReader({}),
emptyIds,
);
expect(result.get(external.myRoute)).toBe(ref);
});
it('throws on unknown keys', () => {
const external = { myRoute: createExternalRouteRef() };
const ref = createRouteRef();
expect(() =>
resolveRouteBindings(
({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
},
new ConfigReader({}),
emptyIds,
),
).toThrow('Key someOtherRoute is not an existing external route');
});
it('reads bindings from config', () => {
const mySource = createExternalRouteRef();
const myTarget = createRouteRef();
const result = resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
{
routes: new Map([['myTarget', myTarget]]),
externalRoutes: new Map([['mySource', mySource]]),
},
);
expect(result.get(mySource)).toBe(myTarget);
});
it('throws on invalid config', () => {
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({ app: { routes: { bindings: 'derp' } } }),
emptyIds,
),
).toThrow(
"Invalid type in config for key 'app.routes.bindings' in 'mock-config', got string, wanted object",
);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }),
emptyIds,
),
).toThrow(
"Invalid config at app.routes.bindings['mySource'], value must be a non-empty string",
);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
emptyIds,
),
).toThrow(
"Invalid config at app.routes.bindings, 'mySource' is not a valid external route",
);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
{
...emptyIds,
externalRoutes: new Map([['mySource', createExternalRouteRef()]]),
},
),
).toThrow(
"Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route",
);
});
});
@@ -0,0 +1,142 @@
/*
* Copyright 2020 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,
ExternalRouteRef,
} from '@backstage/frontend-plugin-api';
import { RouteRefsById } from './collectRouteIds';
import { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
/**
* Extracts a union of the keys in a map whose value extends the given type
*
* @ignore
*/
type KeysWithType<Obj extends { [key in string]: any }, Type> = {
[key in keyof Obj]: Obj[key] extends Type ? key : never;
}[keyof Obj];
/**
* Takes a map Map required values and makes all keys matching Keys optional
*
* @ignore
*/
type PartialKeys<
Map extends { [name in string]: any },
Keys extends keyof Map,
> = Partial<Pick<Map, Keys>> & Required<Omit<Map, Keys>>;
/**
* Creates a map of target routes with matching parameters based on a map of external routes.
*
* @ignore
*/
type TargetRouteMap<
ExternalRoutes extends { [name: string]: ExternalRouteRef },
> = {
[name in keyof ExternalRoutes]: ExternalRoutes[name] extends ExternalRouteRef<
infer Params,
any
>
? RouteRef<Params> | SubRouteRef<Params>
: never;
};
/**
* A function that can bind from external routes of a given plugin, to concrete
* routes of other plugins. See {@link createApp}.
*
* @public
*/
export type AppRouteBinder = <
TExternalRoutes extends { [name: string]: ExternalRouteRef },
>(
externalRoutes: TExternalRoutes,
targetRoutes: PartialKeys<
TargetRouteMap<TExternalRoutes>,
KeysWithType<TExternalRoutes, ExternalRouteRef<any, true>>
>,
) => void;
/** @internal */
export function resolveRouteBindings(
bindRoutes: ((context: { bind: AppRouteBinder }) => void) | undefined,
config: Config,
routesById: RouteRefsById,
): Map<ExternalRouteRef, RouteRef | SubRouteRef> {
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
if (bindRoutes) {
const bind: AppRouteBinder = (
externalRoutes,
targetRoutes: { [name: string]: RouteRef | SubRouteRef },
) => {
for (const [key, value] of Object.entries(targetRoutes)) {
const externalRoute = externalRoutes[key];
if (!externalRoute) {
throw new Error(`Key ${key} is not an existing external route`);
}
if (!value && !externalRoute.optional) {
throw new Error(
`External route ${key} is required but was undefined`,
);
}
if (value) {
result.set(externalRoute, value);
}
}
};
bindRoutes({ bind });
}
const bindingsConfig = config.getOptionalConfig('app.routes.bindings');
if (!bindingsConfig) {
return result;
}
const bindings = bindingsConfig.get<JsonObject>();
for (const [externalRefId, targetRefId] of Object.entries(bindings)) {
if (typeof targetRefId !== 'string' || targetRefId === '') {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string`,
);
}
const externalRef = routesById.externalRoutes.get(externalRefId);
if (!externalRef) {
throw new Error(
`Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
);
}
// Route bindings defined in config have lower priority than those defined in code
if (result.has(externalRef)) {
continue;
}
const targetRef = routesById.routes.get(targetRefId);
if (!targetRef) {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`,
);
}
result.set(externalRef, targetRef);
}
return result;
}
@@ -0,0 +1,38 @@
/*
* Copyright 2023 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,
RouteRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import { BackstagePlugin as LegacyBackstagePlugin } from '@backstage/core-plugin-api';
/** @internal */
export type AnyRouteRef = RouteRef | SubRouteRef | ExternalRouteRef;
/**
* A duplicate of the react-router RouteObject, but with routeRef added
* @internal
*/
export interface BackstageRouteObject {
caseSensitive: boolean;
children?: BackstageRouteObject[];
element: React.ReactNode;
path: string;
routeRefs: Set<RouteRef>;
plugins: Set<LegacyBackstagePlugin>;
}
@@ -19,13 +19,13 @@ import {
createExtensionOverrides,
createPageExtension,
createPlugin,
createRouteRef,
createThemeExtension,
} from '@backstage/frontend-plugin-api';
import { createApp, createInstances } from './createApp';
import { screen } from '@testing-library/react';
import { MockConfigApi, renderWithEffects } from '@backstage/test-utils';
import React from 'react';
import { createRouteRef } from '@backstage/core-plugin-api';
const extBaseConfig = {
id: 'test',
@@ -83,14 +83,14 @@ describe('createInstances', () => {
const ExtensionA = createPageExtension({
id: 'A',
defaultPath: '/',
routeRef: createRouteRef({ id: 'A.route' }),
routeRef: createRouteRef(),
loader: async () => <div>Extension A</div>,
});
const ExtensionB = createPageExtension({
id: 'B',
defaultPath: '/',
routeRef: createRouteRef({ id: 'B.route' }),
routeRef: createRouteRef(),
loader: async () => <div>Extension B</div>,
});
@@ -21,6 +21,8 @@ import {
coreExtensionData,
ExtensionDataRef,
ExtensionOverrides,
RouteRef,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { Core } from '../extensions/Core';
import { CoreRoutes } from '../extensions/CoreRoutes';
@@ -44,11 +46,9 @@ import {
ConfigApi,
configApiRef,
IconComponent,
RouteRef,
BackstagePlugin as LegacyBackstagePlugin,
featureFlagsApiRef,
attachComponentData,
useRouteRef,
identityApiRef,
AppTheme,
} from '@backstage/core-plugin-api';
@@ -57,7 +57,6 @@ import {
ApiFactoryRegistry,
ApiProvider,
ApiResolver,
AppRouteBinder,
AppThemeSelector,
} from '@backstage/core-app-api';
@@ -75,10 +74,6 @@ import { defaultConfigLoaderSync } from '../../../core-app-api/src/app/defaultCo
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { overrideBaseUrlConfigs } from '../../../core-app-api/src/app/overrideBaseUrlConfigs';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { RoutingProvider } from '../../../core-app-api/src/routing/RoutingProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveRouteBindings } from '../../../core-app-api/src/app/resolveRouteBindings';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppLanguageSelector } from '../../../core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { I18nextTranslationApi } from '../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
@@ -97,6 +92,10 @@ import {
appLanguageApiRef,
translationApiRef,
} from '@backstage/core-plugin-api/alpha';
import { AppRouteBinder } from '../routing';
import { RoutingProvider } from '../routing/RoutingProvider';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
/** @public */
export interface ExtensionTreeNode {
@@ -307,13 +306,19 @@ export function createApp(options: {
),
);
const routeIds = collectRouteIds(allFeatures);
const App = () => (
<ApiProvider apis={createApiHolder(coreInstance, config)}>
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider
{...extractRouteInfoFromInstanceTree(coreInstance)}
routeBindings={resolveRouteBindings(options.bindRoutes)}
routeBindings={resolveRouteBindings(
options.bindRoutes,
config,
routeIds,
)}
>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>
+136 -8
View File
@@ -7,15 +7,12 @@
import { AnyApiFactory } from '@backstage/core-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { AppTheme } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { z } from 'zod';
import { ZodSchema } from 'zod';
import { ZodTypeDef } from 'zod';
@@ -41,6 +38,23 @@ export type AnyExtensionInputMap = {
>;
};
// @public (undocumented)
export type AnyExternalRoutes = {
[name in string]: ExternalRouteRef;
};
// @public
export type AnyRouteRefParams =
| {
[param in string]: string;
}
| undefined;
// @public (undocumented)
export type AnyRoutes = {
[name in string]: RouteRef;
};
// @public (undocumented)
export interface BackstagePlugin<
Routes extends AnyRoutes = AnyRoutes,
@@ -79,7 +93,7 @@ export const coreExtensionData: {
reactElement: ConfigurableExtensionDataRef<JSX_2.Element, {}>;
routePath: ConfigurableExtensionDataRef<string, {}>;
apiFactory: ConfigurableExtensionDataRef<AnyApiFactory, {}>;
routeRef: ConfigurableExtensionDataRef<RouteRef, {}>;
routeRef: ConfigurableExtensionDataRef<RouteRef<AnyRouteRefParams>, {}>;
navTarget: ConfigurableExtensionDataRef<NavTarget, {}>;
theme: ConfigurableExtensionDataRef<AppTheme, {}>;
};
@@ -173,10 +187,35 @@ export function createExtensionOverrides(
options: ExtensionOverridesOptions,
): ExtensionOverrides;
// @public
export function createExternalRouteRef<
TParams extends
| {
[param in TParamKeys]: string;
}
| undefined = undefined,
TOptional extends boolean = false,
TParamKeys extends string = string,
>(options?: {
readonly params?: string extends TParamKeys
? (keyof TParams)[]
: TParamKeys[];
optional?: TOptional;
}): ExternalRouteRef<
keyof TParams extends never
? undefined
: string extends TParamKeys
? TParams
: {
[param in TParamKeys]: string;
},
TOptional
>;
// @public
export function createNavItemExtension(options: {
id: string;
routeRef: RouteRef;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent;
}): Extension<{
@@ -215,17 +254,46 @@ export function createPageExtension<
// @public (undocumented)
export function createPlugin<
Routes extends AnyRoutes = AnyRoutes,
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {},
>(
options: PluginOptions<Routes, ExternalRoutes>,
): BackstagePlugin<Routes, ExternalRoutes>;
// @public
export function createRouteRef<
TParams extends
| {
[param in TParamKeys]: string;
}
| undefined = undefined,
TParamKeys extends string = string,
>(config?: {
readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[];
}): RouteRef<
keyof TParams extends never
? undefined
: string extends TParamKeys
? TParams
: {
[param in TParamKeys]: string;
}
>;
// @public (undocumented)
export function createSchemaFromZod<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput>;
// @public
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyRouteRefParams = never,
>(config: {
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams>;
// @public (undocumented)
export function createThemeExtension(theme: AppTheme): Extension<never>;
@@ -344,11 +412,24 @@ export interface ExtensionOverridesOptions {
extensions: Extension<unknown>[];
}
// @public
export interface ExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
TOptional extends boolean = boolean,
> {
// (undocumented)
readonly $$type: '@backstage/ExternalRouteRef';
// (undocumented)
readonly optional: TOptional;
// (undocumented)
readonly T: TParams;
}
// @public (undocumented)
export type NavTarget = {
title: string;
icon: IconComponent;
routeRef: RouteRef<{}>;
routeRef: RouteRef<undefined>;
};
// @public (undocumented)
@@ -371,4 +452,51 @@ export type PortableSchema<TOutput> = {
parse: (input: unknown) => TOutput;
schema: JsonObject;
};
// @public
export type RouteFunc<TParams extends AnyRouteRefParams> = (
...[params]: TParams extends undefined
? readonly []
: readonly [params: TParams]
) => string;
// @public
export interface RouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
// (undocumented)
readonly $$type: '@backstage/RouteRef';
// (undocumented)
readonly T: TParams;
}
// @public
export interface SubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
// (undocumented)
readonly $$type: '@backstage/SubRouteRef';
// (undocumented)
readonly path: string;
// (undocumented)
readonly T: TParams;
}
// @public
export function useRouteRef<
TOptional extends boolean,
TParams extends AnyRouteRefParams,
>(
routeRef: ExternalRouteRef<TParams, TOptional>,
): TParams extends true ? RouteFunc<TParams> | undefined : RouteFunc<TParams>;
// @public
export function useRouteRef<TParams extends AnyRouteRefParams>(
routeRef: RouteRef<TParams> | SubRouteRef<TParams>,
): RouteFunc<TParams>;
// @public
export function useRouteRefParams<Params extends AnyRouteRefParams>(
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
): Params;
```
+4 -1
View File
@@ -27,7 +27,9 @@
"@backstage/frontend-app-api": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^12.1.3"
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.1",
"history": "^5.3.0"
},
"files": [
"dist"
@@ -39,6 +41,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0",
"lodash": "^4.17.21",
"zod": "^3.21.4",
@@ -21,7 +21,8 @@ import {
createExtension,
coreExtensionData,
} from '../wiring';
import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
import { AnyExtensionInputMap } from '../wiring/createExtension';
import { Expand } from '../types';
/** @public */
export function createApiExtension<
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { IconComponent, RouteRef } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
import { coreExtensionData, createExtension } from '../wiring';
import { RouteRef } from '../routing';
/**
* Helper for creating extensions for a nav item.
@@ -24,7 +25,7 @@ import { coreExtensionData, createExtension } from '../wiring';
*/
export function createNavItemExtension(options: {
id: string;
routeRef: RouteRef;
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent;
}) {
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { RouteRef } from '@backstage/core-plugin-api';
import React from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../schema';
@@ -24,7 +23,9 @@ import {
Extension,
ExtensionInputValues,
} from '../wiring';
import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
import { AnyExtensionInputMap } from '../wiring/createExtension';
import { Expand } from '../types';
import { RouteRef } from '../routing';
/**
* Helper for creating extensions for a routable React page component.
@@ -22,5 +22,6 @@
export * from './components';
export * from './extensions';
export * from './routing';
export * from './schema';
export * from './wiring';
@@ -0,0 +1,114 @@
/*
* Copyright 2020 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,
createExternalRouteRef,
toInternalExternalRouteRef,
} from './ExternalRouteRef';
import { AnyRouteRefParams } from './types';
describe('ExternalRouteRef', () => {
it('should be created', () => {
const routeRef: ExternalRouteRef<undefined> = createExternalRouteRef();
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual([]);
expect(internal.optional).toBe(false);
expect(String(internal)).toMatch(
/^ExternalRouteRef\{created at '.*ExternalRouteRef\.test\.ts.*'\}$/,
);
internal.setId('some-id');
expect(String(internal)).toBe('ExternalRouteRef{some-id}');
});
it('should be created as optional', () => {
const routeRef: ExternalRouteRef<undefined, true> = createExternalRouteRef({
params: [],
optional: true,
});
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual([]);
expect(internal.optional).toEqual(true);
});
it('should be created with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({ params: ['x', 'y'] });
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.optional).toEqual(false);
});
it('should be created as optional with params', () => {
const routeRef: ExternalRouteRef<{
x: string;
y: string;
}> = createExternalRouteRef({ params: ['x', 'y'], optional: true });
const internal = toInternalExternalRouteRef(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.optional).toEqual(true);
});
it('should properly infer and validate parameter types and assignments', () => {
function checkRouteRef<
T extends AnyRouteRefParams,
TOptional extends boolean,
TCheck extends TOptional,
>(
_ref: ExternalRouteRef<T, TOptional>,
_params: T extends undefined ? undefined : T,
_optional: TCheck,
) {}
const _1 = createExternalRouteRef({ params: ['notX'] });
checkRouteRef(_1, { notX: '' }, false);
// @ts-expect-error
checkRouteRef(_1, { x: '' }, false);
const _2 = createExternalRouteRef({ params: ['x'], optional: true });
checkRouteRef(_2, { x: '' }, true);
// @ts-expect-error
checkRouteRef(_2, undefined, false);
const _3 = createExternalRouteRef({ params: ['x', 'y'] });
checkRouteRef(_3, { x: '', y: '' }, false);
// @ts-expect-error
checkRouteRef(_3, { x: '' }, false);
// @ts-expect-error
checkRouteRef(_3, { x: '', y: '', z: '' }, false);
const _4 = createExternalRouteRef({ params: [] });
checkRouteRef(_4, undefined, false);
// @ts-expect-error
checkRouteRef<any>(_4, { x: '' });
const _5 = createExternalRouteRef();
checkRouteRef(_5, undefined, false);
// @ts-expect-error
checkRouteRef<any>(_5, { x: '' });
const _6 = createExternalRouteRef({ optional: true });
checkRouteRef(_6, undefined, true);
// @ts-expect-error
checkRouteRef(_6, undefined, false);
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,131 @@
/*
* Copyright 2020 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 { RouteRefImpl } from './RouteRef';
import { describeParentCallSite } from './describeParentCallSite';
import { AnyRouteRefParams } from './types';
/**
* Route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export interface ExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
TOptional extends boolean = boolean,
> {
readonly $$type: '@backstage/ExternalRouteRef';
readonly T: TParams;
readonly optional: TOptional;
}
/** @internal */
export interface InternalExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
TOptional extends boolean = boolean,
> extends ExternalRouteRef<TParams, TOptional> {
readonly version: 'v1';
getParams(): string[];
getDescription(): string;
setId(id: string): void;
}
/** @internal */
export function toInternalExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
TOptional extends boolean = boolean,
>(
resource: ExternalRouteRef<TParams, TOptional>,
): InternalExternalRouteRef<TParams, TOptional> {
const r = resource as InternalExternalRouteRef<TParams, TOptional>;
if (r.$$type !== '@backstage/ExternalRouteRef') {
throw new Error(`Invalid ExternalRouteRef, bad type '${r.$$type}'`);
}
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;
constructor(
readonly optional: boolean,
readonly params: string[] = [],
creationSite: string,
) {
super(params, creationSite);
}
}
/**
* Creates a route descriptor, to be later bound to a concrete route by the app. Used to implement cross-plugin route references.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @param options - Description of the route reference to be created.
* @public
*/
export function createExternalRouteRef<
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
TOptional extends boolean = false,
TParamKeys extends string = string,
>(options?: {
/**
* The parameters that will be provided to the external route reference.
*/
readonly params?: string extends TParamKeys
? (keyof TParams)[]
: TParamKeys[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useExternalRouteRef` will return `undefined`.
*/
optional?: TOptional;
}): ExternalRouteRef<
keyof TParams extends never
? undefined
: string extends TParamKeys
? TParams
: { [param in TParamKeys]: string },
TOptional
> {
return new ExternalRouteRefImpl(
Boolean(options?.optional),
options?.params as string[] | undefined,
describeParentCallSite(),
) as ExternalRouteRef<any, any>;
}
@@ -0,0 +1,93 @@
/*
* Copyright 2020 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 { AnyRouteRefParams } from './types';
import { RouteRef, createRouteRef, toInternalRouteRef } from './RouteRef';
describe('RouteRef', () => {
it('should be created and have a mutable ID', () => {
const routeRef: RouteRef<undefined> = createRouteRef();
const internal = toInternalRouteRef(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.*\}$/,
);
expect(() => internal.setId('')).toThrow(
'RouteRef id must be a non-empty string',
);
internal.setId('some-id');
expect(String(internal)).toBe('RouteRef{some-id}');
expect(() => internal.setId('some-other-id')).toThrow(
"RouteRef was referenced twice as both 'some-id' and 'some-other-id'",
);
});
it('should be created with params', () => {
const routeRef: RouteRef<{
x: string;
y: string;
}> = createRouteRef({
params: ['x', 'y'],
});
const internal = toInternalRouteRef(routeRef);
expect(internal.getParams()).toEqual(['x', 'y']);
expect(internal.getDescription()).toMatch(/RouteRef\.test\.ts/);
});
it('should properly infer and validate parameter types and assignments', () => {
function checkRouteRef<T extends AnyRouteRefParams>(
_ref: RouteRef<T>,
_params: T extends undefined ? undefined : T,
) {}
const _1 = createRouteRef({ params: ['x'] });
checkRouteRef(_1, { x: '' });
// @ts-expect-error
checkRouteRef(_1, { y: '' });
// @ts-expect-error
checkRouteRef(_1, undefined);
const _2 = createRouteRef({ params: ['x', 'y'] });
checkRouteRef(_2, { x: '', y: '' });
// @ts-expect-error
checkRouteRef(_2, { x: '' });
// @ts-expect-error
checkRouteRef(_2, undefined);
// @ts-expect-error
checkRouteRef(_2, { x: '', z: '' });
// @ts-expect-error
checkRouteRef(_2, { x: '', y: '', z: '' });
const _3 = createRouteRef({ params: [] });
checkRouteRef(_3, undefined);
// @ts-expect-error
checkRouteRef(_3, { x: '' });
const _4 = createRouteRef();
checkRouteRef(_4, undefined);
// @ts-expect-error
checkRouteRef(_4, { x: '' });
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,136 @@
/*
* Copyright 2020 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 { describeParentCallSite } from './describeParentCallSite';
import { AnyRouteRefParams } from './types';
/**
* Absolute route reference.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export interface RouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
readonly $$type: '@backstage/RouteRef';
readonly T: TParams;
}
/** @internal */
export interface InternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> extends RouteRef<TParams> {
readonly version: 'v1';
getParams(): string[];
getDescription(): string;
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;
#params: string[];
#creationSite: string;
constructor(readonly params: string[] = [], creationSite: string) {
this.#params = params;
this.#creationSite = creationSite;
}
getParams(): string[] {
return this.#params;
}
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) {
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.
*
* @param config - Description of the route reference to be created.
* @public
*/
export function createRouteRef<
// Params is the type that we care about and the one to be embedded in the route ref.
// For example, given the params ['name', 'kind'], Params will be {name: string, kind: string}
TParams extends { [param in TParamKeys]: string } | undefined = undefined,
TParamKeys extends string = string,
>(config?: {
/** A list of parameter names that the path that this route ref is bound to must contain */
readonly params: string extends TParamKeys ? (keyof TParams)[] : TParamKeys[];
}): RouteRef<
keyof TParams extends never
? undefined
: string extends TParamKeys
? TParams
: { [param in TParamKeys]: string }
> {
return new RouteRefImpl(
config?.params as string[] | undefined,
describeParentCallSite(),
) as RouteRef<any>;
}
@@ -0,0 +1,139 @@
/*
* Copyright 2020 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 { AnyRouteRefParams } from './types';
import {
SubRouteRef,
createSubRouteRef,
toInternalSubRouteRef,
} from './SubRouteRef';
import { createRouteRef, toInternalRouteRef } from './RouteRef';
const parent = createRouteRef();
const parentX = createRouteRef({ params: ['x'] });
describe('SubRouteRef', () => {
it('should be created', () => {
const internalParent = toInternalRouteRef(createRouteRef());
const routeRef: SubRouteRef = createSubRouteRef({
parent: internalParent,
path: '/foo',
});
const internal = toInternalSubRouteRef(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.*'\}/,
);
internalParent.setId('some-id');
expect(String(internal)).toBe('SubRouteRef{at /foo with parent some-id}');
});
it('should be created with params', () => {
const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({
parent,
path: '/foo/:bar',
});
const internal = toInternalSubRouteRef(routeRef);
expect(internal.path).toBe('/foo/:bar');
expect(internal.getParent()).toBe(parent);
expect(internal.getParams()).toEqual(['bar']);
});
it('should be created with merged params', () => {
const routeRef: SubRouteRef<{
x: string;
y: string;
z: string;
}> = createSubRouteRef({
parent: parentX,
path: '/foo/:y/:z',
});
const internal = toInternalSubRouteRef(routeRef);
expect(internal.path).toBe('/foo/:y/:z');
expect(internal.getParent()).toBe(parentX);
expect(internal.getParams()).toEqual(['x', 'y', 'z']);
});
it('should be created with params from parent', () => {
const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({
parent: parentX,
path: '/foo/bar',
});
const internal = toInternalSubRouteRef(routeRef);
expect(internal.path).toBe('/foo/bar');
expect(internal.getParent()).toBe(parentX);
expect(internal.getParams()).toEqual(['x']);
});
it.each([
['foo', "SubRouteRef path must start with '/', got 'foo'"],
[':foo', "SubRouteRef path must start with '/', got ':foo'"],
['', "SubRouteRef path must start with '/', got ''"],
['/', "SubRouteRef path must not end with '/', got '/'"],
['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"],
['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'],
['/:/foo', "SubRouteRef path has invalid param, got ''"],
['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"],
['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"],
])('should throw if path is invalid, %s', (path, message) => {
expect(() => createSubRouteRef({ path, parent: parentX })).toThrow(message);
});
it('should properly infer and parse path parameters', () => {
function checkSubRouteRef<T extends AnyRouteRefParams>(
_ref: SubRouteRef<T>,
_params: T extends undefined ? undefined : T,
) {}
const _1 = createSubRouteRef({ parent, path: '/foo/bar' });
// @ts-expect-error
checkSubRouteRef(_1, { x: '' });
checkSubRouteRef(_1, undefined);
const _2 = createSubRouteRef({ parent, path: '/foo/:x/:y' });
// @ts-expect-error
checkSubRouteRef(_2, undefined);
// @ts-expect-error
checkSubRouteRef(_2, { x: '', z: '' });
// @ts-expect-error
checkSubRouteRef(_2, { y: '' });
// @ts-expect-error
checkSubRouteRef(_2, { x: '', y: '', z: '' });
checkSubRouteRef(_2, { x: '', y: '' });
const _3 = createSubRouteRef({ parent: parentX, path: '/foo' });
// @ts-expect-error
checkSubRouteRef(_3, undefined);
// @ts-expect-error
checkSubRouteRef(_3, { y: '' });
checkSubRouteRef(_3, { x: '' });
const _4 = createSubRouteRef({ parent: parentX, path: '/foo/:y' });
// @ts-expect-error
checkSubRouteRef(_4, undefined);
// @ts-expect-error
checkSubRouteRef(_4, { x: '', z: '' });
// @ts-expect-error
checkSubRouteRef(_4, { y: '' });
checkSubRouteRef(_4, { x: '', y: '' });
// To avoid complains about missing expectations and unused vars
expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String));
});
});
@@ -0,0 +1,208 @@
/*
* Copyright 2020 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, toInternalRouteRef } from './RouteRef';
import { AnyRouteRefParams } from './types';
// Should match the pattern in react-router
const PARAM_PATTERN = /^\w+$/;
/**
* Descriptor of a route relative to an absolute {@link RouteRef}.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}.
*
* @public
*/
export interface SubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
readonly $$type: '@backstage/SubRouteRef';
readonly T: TParams;
readonly path: string;
}
/** @internal */
export interface InternalSubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> extends SubRouteRef<TParams> {
readonly version: 'v1';
getParams(): string[];
getParent(): RouteRef;
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
*/
type ParamPart<S extends string> = S extends `:${infer Param}` ? Param : never;
/**
* Used in {@link PathParams} type declaration.
* @ignore
*/
type ParamNames<S extends string> = S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
/**
* This utility type helps us infer a Param object type from a string path
* For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
* @ignore
*/
type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with an optional params type into a params object.
* @ignore
*/
type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyRouteRefParams,
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Convert empty params to undefined.
* @ignore
*/
type TrimEmptyParams<Params extends { [param in string]: string }> =
keyof Params extends never ? undefined : Params;
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*
* @ignore
*/
type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyRouteRefParams,
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<TrimEmptyParams<MergeParams<Params, ParentParams>>>
: never;
/**
* Create a {@link SubRouteRef} from a route descriptor.
*
* @param config - Description of the route reference to be created.
* @public
*/
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyRouteRefParams = never,
>(config: {
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { path, parent } = config;
type Params = PathParams<Path>;
const internalParent = toInternalRouteRef(parent);
const parentParams = internalParent.getParams();
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path
.split('/')
.filter(p => p.startsWith(':'))
.map(p => p.substring(1));
const params = [...parentParams, ...pathParams];
if (parentParams.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path must start with '/', got '${path}'`);
}
if (path.endsWith('/')) {
throw new Error(`SubRouteRef path must not end with '/', got '${path}'`);
}
for (const param of pathParams) {
if (!PARAM_PATTERN.test(param)) {
throw new Error(`SubRouteRef path has invalid param, got '${param}'`);
}
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
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;
}
@@ -0,0 +1,84 @@
/*
* Copyright 2023 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 { describeParentCallSite } from './describeParentCallSite';
class ChromeError {
name = 'Error';
message = 'eHgtF5hmbrXyiEvo';
get stack() {
return `Error: eHgtF5hmbrXyiEvo
at describeParentCallSite (describeParentCallSite.js:1:11)
at parent (parent.js:2:22)
at caller (parent-caller.js:3:33)
at other.js:3:33`;
}
}
class SafariError {
name = 'Error';
message = 'eHgtF5hmbrXyiEvo';
get stack() {
return `describeParentCallSite@describeParentCallSite.js:1:11
parent@parent.js:2:22
caller@parent-caller.js:3:33
code@other.js:3:33`;
}
}
class FirefoxError {
name = 'Error';
message = 'eHgtF5hmbrXyiEvo';
get stack() {
return `describeParentCallSite@describeParentCallSite.js:1:11
parent@parent.js:2:22
caller@parent-caller.js:3:33
@other.js:3:33
`;
}
}
describe('describeParentCallSite', () => {
it('should work in Chrome', () => {
function myFactory() {
return describeParentCallSite(ChromeError);
}
function myCaller() {
return myFactory();
}
expect(myCaller()).toBe('parent-caller.js:3:33');
});
it('should work in Safari', () => {
function myFactory() {
return describeParentCallSite(SafariError);
}
function myCaller() {
return myFactory();
}
expect(myCaller()).toBe('parent-caller.js:3:33');
});
it('should work in Firefox', () => {
function myFactory() {
return describeParentCallSite(FirefoxError);
}
function myCaller() {
return myFactory();
}
expect(myCaller()).toBe('parent-caller.js:3:33');
});
});
@@ -0,0 +1,59 @@
/*
* Copyright 2023 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.
*/
const MESSAGE_MARKER = 'eHgtF5hmbrXyiEvo';
/**
* Internal helper that describes the location of the parent caller.
* @internal
*/
export function describeParentCallSite(
ErrorConstructor: { new (message: string): Error } = Error,
): string {
const { stack } = new ErrorConstructor(MESSAGE_MARKER);
if (!stack) {
return '<unknown>';
}
// Safari and Firefox don't include the error itself in the stack
const startIndex = stack.includes(MESSAGE_MARKER)
? stack.indexOf('\n') + 1
: 0;
const secondEntryStart =
stack.indexOf('\n', stack.indexOf('\n', startIndex) + 1) + 1;
const secondEntryEnd = stack.indexOf('\n', secondEntryStart);
const line = stack.substring(secondEntryStart, secondEntryEnd).trim();
if (!line) {
return 'unknown';
}
// Below we try to extract the location for different browsers.
// Since RouteRefs are declared at the top-level of modules the caller name isn't interesting.
// Chrome
if (line.includes('(')) {
return line.substring(line.indexOf('(') + 1, line.indexOf(')'));
}
// Safari & Firefox
if (line.includes('@')) {
return line.substring(line.indexOf('@') + 1);
}
// Give up
return line;
}
@@ -0,0 +1,25 @@
/*
* Copyright 2020 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 type { AnyRouteRefParams } from './types';
export { createRouteRef, type RouteRef } from './RouteRef';
export { createSubRouteRef, type SubRouteRef } from './SubRouteRef';
export {
createExternalRouteRef,
type ExternalRouteRef,
} from './ExternalRouteRef';
export { useRouteRef, type RouteFunc } from './useRouteRef';
export { useRouteRefParams } from './useRouteRefParams';
@@ -0,0 +1,22 @@
/*
* Copyright 2020 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.
*/
/**
* Catch-all type for route params.
*
* @public
*/
export type AnyRouteRefParams = { [param in string]: string } | undefined;
@@ -0,0 +1,157 @@
/*
* Copyright 2020 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 { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { MemoryRouter, Router } from 'react-router-dom';
import { createVersionedContextForTesting } from '@backstage/version-bridge';
import { useRouteRef } from './useRouteRef';
import { createRouteRef } from './RouteRef';
import { createBrowserHistory } from 'history';
describe('v1 consumer', () => {
const context = createVersionedContextForTesting('routing-context');
afterEach(() => {
context.reset();
});
it('should resolve routes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const renderedHook = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<MemoryRouter initialEntries={['/my-page']} children={children} />
),
});
const routeFunc = renderedHook.result.current;
expect(routeFunc()).toBe('/hello');
expect(resolve).toHaveBeenCalledWith(
routeRef,
expect.objectContaining({
pathname: '/my-page',
}),
);
});
it('re-resolves the routeFunc when the search parameters change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-new-page');
rerender();
expect(resolve).toHaveBeenCalledTimes(2);
});
it('does not re-resolve the routeFunc the location pathname does not change', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
it('does not re-resolve the routeFunc when the search parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page?foo=bar');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
it('does not re-resolve the routeFunc when the hash parameter changes', () => {
const resolve = jest.fn(() => () => '/hello');
context.set({ 1: { resolve } });
const routeRef = createRouteRef();
const history = createBrowserHistory();
history.push('/my-page');
const { rerender } = renderHook(() => useRouteRef(routeRef), {
wrapper: ({ children }: React.PropsWithChildren<{}>) => (
<Router
location={history.location}
navigator={history}
children={children}
/>
),
});
expect(resolve).toHaveBeenCalledTimes(1);
history.push('/my-page#foo');
rerender();
expect(resolve).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,134 @@
/*
* Copyright 2020 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 { useMemo } from 'react';
import { matchRoutes, useLocation } from 'react-router-dom';
import { useVersionedContext } from '@backstage/version-bridge';
import { AnyRouteRefParams } from './types';
import { RouteRef } from './RouteRef';
import { SubRouteRef } from './SubRouteRef';
import { ExternalRouteRef } from './ExternalRouteRef';
/**
* TS magic for handling route parameters.
*
* @remarks
*
* The extra TS magic here is to require a single params argument if the RouteRef
* had at least one param defined, but require 0 arguments if there are no params defined.
* Without this we'd have to pass in empty object to all parameter-less RouteRefs
* just to make TypeScript happy, or we would have to make the argument optional in
* which case you might forget to pass it in when it is actually required.
*
* @public
*/
export type RouteFunc<TParams extends AnyRouteRefParams> = (
...[params]: TParams extends undefined
? readonly []
: readonly [params: TParams]
) => string;
/**
* @internal
*/
export interface RouteResolver {
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams, any>,
sourceLocation: Parameters<typeof matchRoutes>[1],
): RouteFunc<TParams> | undefined;
}
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<
TOptional extends boolean,
TParams extends AnyRouteRefParams,
>(
routeRef: ExternalRouteRef<TParams, TOptional>,
): TParams extends true ? RouteFunc<TParams> | undefined : RouteFunc<TParams>;
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<TParams extends AnyRouteRefParams>(
routeRef: RouteRef<TParams> | SubRouteRef<TParams>,
): RouteFunc<TParams>;
/**
* React hook for constructing URLs to routes.
*
* @remarks
*
* See {@link https://backstage.io/docs/plugins/composability#routing-system}
*
* @param routeRef - The ref to route that should be converted to URL.
* @returns A function that will in turn return the concrete URL of the `routeRef`.
* @public
*/
export function useRouteRef<TParams extends AnyRouteRefParams>(
routeRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams, any>,
): RouteFunc<TParams> | undefined {
const { pathname } = useLocation();
const versionedContext = useVersionedContext<{ 1: RouteResolver }>(
'routing-context',
);
if (!versionedContext) {
throw new Error('Routing context is not available');
}
const resolver = versionedContext.atVersion(1);
const routeFunc = useMemo(
() => resolver && resolver.resolve(routeRef, { pathname }),
[resolver, routeRef, pathname],
);
if (!versionedContext) {
throw new Error('useRouteRef used outside of routing context');
}
if (!resolver) {
throw new Error('RoutingContext v1 not available');
}
const isOptional = 'optional' in routeRef && routeRef.optional;
if (!routeFunc && !isOptional) {
throw new Error(`No path for ${routeRef}`);
}
return routeFunc;
}
@@ -0,0 +1,51 @@
/*
* Copyright 2021 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 { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useRouteRefParams } from './useRouteRefParams';
import { createRouteRef } from './RouteRef';
describe('useRouteRefParams', () => {
it('should provide types params', () => {
const routeRef = createRouteRef({
params: ['a', 'b'],
});
const Page = () => {
const params: { a: string; b: string } = useRouteRefParams(routeRef);
return (
<div>
<span>{params.a}</span>
<span>{params.b}</span>
</div>
);
};
const { getByText } = render(
<MemoryRouter initialEntries={['/foo/bar']}>
<Routes>
<Route path="/:a/:b" element={<Page />} />
</Routes>
</MemoryRouter>,
);
expect(getByText('foo')).toBeInTheDocument();
expect(getByText('bar')).toBeInTheDocument();
});
});
@@ -0,0 +1,31 @@
/*
* Copyright 2021 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 { useParams } from 'react-router-dom';
import { AnyRouteRefParams } from './types';
import { RouteRef } from './RouteRef';
import { SubRouteRef } from './SubRouteRef';
/**
* React hook for retrieving dynamic params from the current URL.
* @param _routeRef - Ref of the current route.
* @public
*/
export function useRouteRefParams<Params extends AnyRouteRefParams>(
_routeRef: RouteRef<Params> | SubRouteRef<Params>,
): Params {
return useParams() as Params;
}
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2023 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.
*/
// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types?
/**
* Utility type to expand type aliases into their equivalent type.
* @ignore
*/
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
@@ -19,15 +19,15 @@ import {
AnyApiFactory,
AppTheme,
IconComponent,
RouteRef,
} from '@backstage/core-plugin-api';
import { createExtensionDataRef } from './createExtensionDataRef';
import { RouteRef } from '../routing';
/** @public */
export type NavTarget = {
title: string;
icon: IconComponent;
routeRef: RouteRef<{}>;
routeRef: RouteRef<undefined>;
};
/** @public */
@@ -15,6 +15,7 @@
*/
import { PortableSchema } from '../schema';
import { Expand } from '../types';
import { ExtensionDataRef } from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import { BackstagePlugin } from './createPlugin';
@@ -32,13 +33,6 @@ export type AnyExtensionInputMap = {
>;
};
// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types?
/**
* Utility type to expand type aliases into their equivalent type.
* @ignore
*/
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
/**
* Converts an extension data map into the matching concrete data values type.
* @public
@@ -14,8 +14,14 @@
* limitations under the License.
*/
import { AnyExternalRoutes, AnyRoutes } from '@backstage/core-plugin-api';
import { Extension } from './createExtension';
import { ExternalRouteRef, RouteRef } from '../routing';
/** @public */
export type AnyRoutes = { [name in string]: RouteRef };
/** @public */
export type AnyExternalRoutes = { [name in string]: ExternalRouteRef };
/** @public */
export interface PluginOptions<
@@ -42,8 +48,8 @@ export interface BackstagePlugin<
/** @public */
export function createPlugin<
Routes extends AnyRoutes = AnyRoutes,
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {},
>(
options: PluginOptions<Routes, ExternalRoutes>,
): BackstagePlugin<Routes, ExternalRoutes> {
@@ -37,6 +37,8 @@ export {
createPlugin,
type BackstagePlugin,
type PluginOptions,
type AnyRoutes,
type AnyExternalRoutes,
} from './createPlugin';
export {
createExtensionOverrides,
+1 -3
View File
@@ -3,8 +3,6 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -26,7 +24,7 @@ export const adrTranslationRef: TranslationRef<
>;
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<{}, {}>;
export default _default;
// (No @packageDocumentation comment for this package)
+1 -3
View File
@@ -3,8 +3,6 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
@@ -17,7 +15,7 @@ export const CatalogSearchResultListItemExtension: Extension<{
}>;
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<{}, {}>;
export default _default;
// @alpha (undocumented)
+1 -3
View File
@@ -3,13 +3,11 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<{}, {}>;
export default _default;
// @alpha (undocumented)
+7 -3
View File
@@ -3,12 +3,11 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { GraphQLEndpoint } from '@backstage/plugin-graphiql';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export function createEndpointExtension<TConfig extends {}>(options: {
@@ -21,7 +20,12 @@ export function createEndpointExtension<TConfig extends {}>(options: {
}): Extension<TConfig>;
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
export default _default;
// @alpha (undocumented)
+8 -9
View File
@@ -32,19 +32,15 @@ import {
GraphQLEndpoint,
GraphiQLIcon,
} from '@backstage/plugin-graphiql';
import {
createApiFactory,
createRouteRef,
IconComponent,
} from '@backstage/core-plugin-api';
const graphiqlRouteRef = createRouteRef({ id: 'plugin.graphiql.page' });
import { createApiFactory, IconComponent } from '@backstage/core-plugin-api';
import { graphiQLRouteRef } from './route-refs';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
/** @alpha */
export const GraphiqlPage = createPageExtension({
id: 'plugin.graphiql.page',
defaultPath: '/graphiql',
routeRef: graphiqlRouteRef,
routeRef: convertLegacyRouteRef(graphiQLRouteRef),
loader: () => import('./components').then(m => <m.GraphiQLPage />),
});
@@ -53,7 +49,7 @@ export const graphiqlPageSidebarItem = createNavItemExtension({
id: 'plugin.graphiql.nav.index',
title: 'GraphiQL',
icon: GraphiQLIcon as IconComponent,
routeRef: graphiqlRouteRef,
routeRef: convertLegacyRouteRef(graphiQLRouteRef),
});
/** @internal */
@@ -125,4 +121,7 @@ export default createPlugin({
gitlabGraphiQLBrowseExtension,
graphiqlPageSidebarItem,
],
routes: {
root: convertLegacyRouteRef(graphiQLRouteRef),
},
});
+7 -3
View File
@@ -3,13 +3,17 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
export default _default;
// @alpha (undocumented)
+7 -5
View File
@@ -28,7 +28,6 @@ import {
useSidebarPinState,
} from '@backstage/core-components';
import {
createRouteRef,
useApi,
DiscoveryApi,
IdentityApi,
@@ -64,9 +63,11 @@ import { SearchResult } from '@backstage/plugin-search-common';
import { searchApiRef } from '@backstage/plugin-search-react';
import { searchResultItemExtensionData } from '@backstage/plugin-search-react/alpha';
import { rootRouteRef } from './plugin';
import { SearchClient } from './apis';
import { SearchType } from './components/SearchType';
import { UrlUpdater } from './components/SearchPage/SearchPage';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
/** @alpha */
export const SearchApi = createApiExtension({
@@ -95,12 +96,10 @@ const useSearchPageStyles = makeStyles((theme: Theme) => ({
},
}));
const searchRouteRef = createRouteRef({ id: 'plugin.search.page' });
/** @alpha */
export const SearchPage = createPageExtension({
id: 'plugin.search.page',
routeRef: searchRouteRef,
routeRef: convertLegacyRouteRef(rootRouteRef),
configSchema: createSchemaFromZod(z =>
z.object({
path: z.string().default('/search'),
@@ -237,7 +236,7 @@ export const SearchPage = createPageExtension({
/** @alpha */
export const SearchNavItem = createNavItemExtension({
id: 'plugin.search.nav.index',
routeRef: searchRouteRef,
routeRef: convertLegacyRouteRef(rootRouteRef),
title: 'Search',
icon: SearchIcon,
});
@@ -246,4 +245,7 @@ export const SearchNavItem = createNavItemExtension({
export default createPlugin({
id: 'plugin.search',
extensions: [SearchApi, SearchPage, SearchNavItem],
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
});
+7 -3
View File
@@ -3,13 +3,17 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
export default _default;
// @alpha (undocumented)
+7 -4
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createApiFactory, createRouteRef } from '@backstage/core-plugin-api';
import { createApiFactory } from '@backstage/core-plugin-api';
import {
createApiExtension,
createPageExtension,
@@ -24,14 +24,14 @@ import {
import React from 'react';
import { techRadarApiRef } from './api';
import { SampleTechRadarApi } from './sample';
const techRadarRouteRef = createRouteRef({ id: 'plugin.techradar.page' });
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { rootRouteRef } from './plugin';
/** @alpha */
export const TechRadarPage = createPageExtension({
id: 'plugin.techradar.page',
defaultPath: '/tech-radar',
routeRef: techRadarRouteRef,
routeRef: convertLegacyRouteRef(rootRouteRef),
configSchema: createSchemaFromZod(z =>
z.object({
title: z.string().default('Tech Radar'),
@@ -59,4 +59,7 @@ const sampleTechRadarApi = createApiExtension({
export default createPlugin({
id: 'tech-radar',
extensions: [TechRadarPage, sampleTechRadarApi],
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
});
+1 -1
View File
@@ -23,7 +23,7 @@ import {
createApiFactory,
} from '@backstage/core-plugin-api';
const rootRouteRef = createRouteRef({
export const rootRouteRef = createRouteRef({
id: 'tech-radar',
});
+12 -3
View File
@@ -3,13 +3,22 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
docRoot: RouteRef<{
name: string;
kind: string;
namespace: string;
}>;
},
{}
>;
export default _default;
// @alpha (undocumented)
+7 -2
View File
@@ -35,6 +35,7 @@ import {
techdocsStorageApiRef,
} from '@backstage/plugin-techdocs-react';
import { TechDocsClient, TechDocsStorageClient } from './client';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
const rootRouteRef = createRouteRef({
id: 'plugin.techdocs.indexPage',
@@ -76,7 +77,7 @@ export const TechDocsSearchResultListItemExtension =
const TechDocsIndexPage = createPageExtension({
id: 'plugin.techdocs.indexPage',
defaultPath: '/docs',
routeRef: rootRouteRef,
routeRef: convertLegacyRouteRef(rootRouteRef),
loader: () =>
import('./home/components/TechDocsIndexPage').then(m => (
<m.TechDocsIndexPage />
@@ -94,7 +95,7 @@ const TechDocsReaderPage = createPageExtension({
import('./reader/components/TechDocsReaderPage').then(m => (
<m.TechDocsReaderPage />
)),
routeRef: rootDocsRouteRef,
routeRef: convertLegacyRouteRef(rootDocsRouteRef),
defaultPath: '/docs/:namespace/:kind/:name/*',
});
@@ -153,4 +154,8 @@ export default createPlugin({
techDocsStorage,
TechDocsSearchResultListItemExtension,
],
routes: {
root: convertLegacyRouteRef(rootRouteRef),
docRoot: convertLegacyRouteRef(rootDocsRouteRef),
},
});
+7 -7
View File
@@ -3,19 +3,19 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyExternalRoutes } from '@backstage/core-plugin-api';
import { AnyRoutes } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
const _default: BackstagePlugin<AnyRoutes, AnyExternalRoutes>;
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
export default _default;
// @alpha (undocumented)
export const userSettingsRouteRef: RouteRef<undefined>;
// @alpha (undocumented)
export const userSettingsTranslationRef: TranslationRef<
'user-settings',
+6 -9
View File
@@ -13,29 +13,23 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
import {
coreExtensionData,
createExtensionInput,
createPageExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { settingsRouteRef } from './plugin';
import React from 'react';
export * from './translation';
/**
* @alpha
*/
export const userSettingsRouteRef = createRouteRef({
id: 'plugin.user-settings.page',
});
const UserSettingsPage = createPageExtension({
id: 'plugin.user-settings.page',
defaultPath: '/settings',
routeRef: userSettingsRouteRef,
routeRef: convertLegacyRouteRef(settingsRouteRef),
inputs: {
providerSettings: createExtensionInput(
{
@@ -56,4 +50,7 @@ const UserSettingsPage = createPageExtension({
export default createPlugin({
id: 'user-settings',
extensions: [UserSettingsPage],
routes: {
root: convertLegacyRouteRef(settingsRouteRef),
},
});
+4 -1
View File
@@ -4234,9 +4234,12 @@ __metadata:
"@backstage/frontend-app-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^12.1.3
"@testing-library/react-hooks": ^8.0.1
"@types/react": ^16.13.1 || ^17.0.0
history: ^5.3.0
lodash: ^4.17.21
zod: ^3.21.4
zod-to-json-schema: ^3.21.4
@@ -27641,7 +27644,7 @@ __metadata:
languageName: node
linkType: hard
"history@npm:^5.0.0":
"history@npm:^5.0.0, history@npm:^5.3.0":
version: 5.3.0
resolution: "history@npm:5.3.0"
dependencies: