diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml
index 49a2c9681c..3d29f19c13 100644
--- a/packages/app-next/app-config.yaml
+++ b/packages/app-next/app-config.yaml
@@ -1,107 +1,146 @@
app:
packages: 'all' # ✨
- extensions:
- - graphiql.page:
- config:
- path: /
- # - core.signInPage:
- # props:
- # provider:
- # id: google
- # title: Google
- # message: Sign In using Google
- # apiRef: googleAuthApiRef # ???
- # - core.nav:
- # config:
- # logo: assets/logo.png
- # layout:
- # - label: Search
- # icon: search
- # to: /search
- # items:
- # - point: search
- # - type: divider
- # - label: Menu
- # icon: menu
- # items:
- # - label: Home
- # icon: home
- # to: /catalog
- # - label: Create...
- # icon: create
- # to: /create
- # - type: divider
- # - type: scroll-wrapper
- # items:
- # - label: Tech Radar
- # icon: map
- # to: /tech-radar
- # - label: GraphiQL
- # icon: graphiql
- # to: /graphiql
- # - type: divider
- # - point: shortcuts
- # - type: space
- # - type: divider
- # - label: Settings
- # icon: avatar
- # to: /settings
- # items:
- # - point: settings
- # - core.nav/search: '@backstage/plugin-search#SidebarSearchModal'
- # - core.nav/shortcuts:
- # use: '@backstage/plugin-shortcuts#Shortcuts'
- # config:
- # allowExternalLinks: true
- # - core.nav/settings: '@backstage/plugin-user-settings#SidebarSettings'
+ # scmAuthExtension: >-
+ # createScmAuthExtension({
+ # id: 'apis.scmAuth.addons.ghe',
+ # factory({ bind }) {
+ # bind.scmAuthAddon({
+ # baseUrl: 'https://github.spotify.net',
+ # api: githubAuthApiRef,
+ # })
+ # }
+ # })
- # - core.pages.index:
- # at:
- # point: core.routes
- # config:
- # path: /
- # use: 'react-router-dom#Navigate'
- # config:
- # to: /catalog
+ # externalRouteRefs:
+ # bind(catalogPlugin.externalRoutes, {
+ # createComponent: scaffolderPlugin.routes.root,
+ # viewTechDoc: techdocsPlugin.routes.docRoot,
+ # createFromTemplate: scaffolderPlugin.routes.selectedTemplate,
+ # });
+ # bind(apiDocsPlugin.externalRoutes, {
+ # registerApi: catalogImportPlugin.routes.importPage,
+ # });
+ # bind(scaffolderPlugin.externalRoutes, {
+ # registerComponent: catalogImportPlugin.routes.importPage,
+ # viewTechDoc: techdocsPlugin.routes.docRoot,
+ # });
+ # bind(orgPlugin.externalRoutes, {
+ # catalogIndex: catalogPlugin.routes.catalogIndex,
+ # });
- # - scaffolder.page:
- # config:
- # groups:
- # - title: Recommended
- # filter:
- # entity.metadata.tags: recommended
- # - scaffolder.page/fields: '@backstage/plugin-scaffolder#LowerCaseValuePickerFieldExtension'
- # - scaffolder.page/layout: '@backstage/plugin-scaffolder#TwoColumnLayout'
+ # extensions:
+ # - plugin.catalog:
+ # config:
+ # externalRoutes:
+ # createComponent: plugin.scaffolder.page
+ # viewTechDoc: plugin.techdocs.docRootPage
+ # createFromTemplate: plugin.scaffolder.templatePage
+ # - graphiql.page:
+ # config:
+ # path: /
+ # - apis.auth.providers.github:
+ # config:
+ # provider: ghe
- # - search.page/content: 'app#customSearchPage' # custom search page from somewhere
+ # - core.signInPage:
+ # props:
+ # provider:
+ # id: google
+ # title: Google
+ # message: Sign In using Google
+ # apiRef: googleAuthApiRef # ???
- # - user-settings.page.routes.advanced:
- # at:
- # point: user-settings.page/routes
- # config:
- # title: Advanced
- # path: /advanced
- # use: '@backstage/plugin-user-settings#AdvancedSettings'
+ # - core.nav:
+ # config:
+ # logo: assets/logo.png
+ # layout:
+ # - label: Search
+ # icon: search
+ # to: /search
+ # items:
+ # - point: search
+ # - type: divider
+ # - label: Menu
+ # icon: menu
+ # items:
+ # - label: Home
+ # icon: home
+ # to: /catalog
+ # - label: Create...
+ # icon: create
+ # to: /create
+ # - type: divider
+ # - type: scroll-wrapper
+ # items:
+ # - label: Tech Radar
+ # icon: map
+ # to: /tech-radar
+ # - label: GraphiQL
+ # icon: graphiql
+ # to: /graphiql
+ # - type: divider
+ # - point: shortcuts
+ # - type: space
+ # - type: divider
+ # - label: Settings
+ # icon: avatar
+ # to: /settings
+ # items:
+ # - point: settings
+ # - core.nav/search: '@backstage/plugin-search#SidebarSearchModal'
+ # - core.nav/shortcuts:
+ # use: '@backstage/plugin-shortcuts#Shortcuts'
+ # config:
+ # allowExternalLinks: true
+ # - core.nav/settings: '@backstage/plugin-user-settings#SidebarSettings'
- # - entity.card.orphanWarning
- # - entity.card.processingErrors
- # - entity.card.about
- # - entity.card.catalogGraph
- # - entity.card.pagerDuty
- # - entity.card.links
- # - entity.card.labels
- # - entity.card.githubInsightsLanguages
- # - entity.card.githubInsightsReleases
- # - entity.card.githubInsightsReadme:
- # config:
- # maxHeight: 350 # Throwing this config in to have an example, but in practice rely on default
- # - entity.card.subcomponentsCard
- # - entity.card.userProfile
- # - entity.card.ownership
- # - entity.card.likeDislikeRatings
- # - entity.content.dependsOnComponents
- # - entity.content.codeInsights
- # - entity.content.todo
- # - entity.content.feedbackResponse
+ # - core.pages.index:
+ # at:
+ # point: core.routes
+ # config:
+ # path: /
+ # use: 'react-router-dom#Navigate'
+ # config:
+ # to: /catalog
+
+ # - scaffolder.page:
+ # config:
+ # groups:
+ # - title: Recommended
+ # filter:
+ # entity.metadata.tags: recommended
+ # - scaffolder.page/fields: '@backstage/plugin-scaffolder#LowerCaseValuePickerFieldExtension'
+ # - scaffolder.page/layout: '@backstage/plugin-scaffolder#TwoColumnLayout'
+
+ # - search.page/content: 'app#customSearchPage' # custom search page from somewhere
+
+ # - user-settings.page.routes.advanced:
+ # at:
+ # point: user-settings.page/routes
+ # config:
+ # title: Advanced
+ # path: /advanced
+ # use: '@backstage/plugin-user-settings#AdvancedSettings'
+
+ # - entity.card.orphanWarning
+ # - entity.card.processingErrors
+ # - entity.card.about
+ # - entity.card.catalogGraph
+ # - entity.card.pagerDuty
+ # - entity.card.links
+ # - entity.card.labels
+ # - entity.card.githubInsightsLanguages
+ # - entity.card.githubInsightsReleases
+ # - entity.card.githubInsightsReadme:
+ # config:
+ # maxHeight: 350 # Throwing this config in to have an example, but in practice rely on default
+ # - entity.card.subcomponentsCard
+ # - entity.card.userProfile
+ # - entity.card.ownership
+ # - entity.card.likeDislikeRatings
+ # - entity.content.dependsOnComponents
+ # - entity.content.codeInsights
+ # - entity.content.todo
+ # - entity.content.feedbackResponse
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index b875fb3927..82b0c927e9 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -17,6 +17,7 @@
import { graphiqlPlugin as legacyGraphiqlPlugin } from '@backstage/plugin-graphiql';
import { createApp as createLegacyApp } from '@backstage/app-defaults';
import { createApp } from '@backstage/frontend-app-api';
+import { pagesPlugin } from './examples/pagesPlugin';
import { graphiqlPlugin } from './examples/graphiqlPlugin';
/*
@@ -49,7 +50,7 @@ TODO:
/* app.tsx */
const app = createApp({
- plugins: [graphiqlPlugin],
+ plugins: [graphiqlPlugin, pagesPlugin],
// bindRoutes({ bind }) {
// bind(catalogPlugin.externalRoutes, {
// createComponent: scaffolderPlugin.routes.root,
diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx
new file mode 100644
index 0000000000..6616c88c4b
--- /dev/null
+++ b/packages/app-next/src/examples/pagesPlugin.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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 React from 'react';
+import { Link } from '@backstage/core-components';
+import {
+ createPageExtension,
+ createPlugin,
+ useRouteRef,
+} from '@backstage/frontend-plugin-api';
+import { createRouteRef } from '@backstage/core-plugin-api';
+import { Route, Routes } from 'react-router-dom';
+
+const indexRouteRef = createRouteRef({ id: 'index' });
+const page1RouteRef = createRouteRef({ id: 'page1' });
+// const page2RouteRef = createSubRouteRef({
+// id: 'page2',
+// parent: page1RouteRef,
+// path: '/page2',
+// });
+
+const IndexPage = createPageExtension({
+ id: 'index',
+ defaultPath: '/',
+ routeRef: indexRouteRef,
+ component: async () => {
+ const Component = () => {
+ const page1Link = useRouteRef(page1RouteRef);
+ return (
+
+ op
+
+ Page 1
+
+
+ GraphiQL
+
+
+ );
+ };
+ return ;
+ },
+});
+
+const Page1 = createPageExtension({
+ id: 'page1',
+ defaultPath: '/page1',
+ routeRef: page1RouteRef,
+ component: async () => {
+ const Component = () => {
+ const indexLink = useRouteRef(indexRouteRef);
+ // const page2Link = useRouteRef(page2RouteRef);
+
+ return (
+
+
This is page 1
+
Go back
+
Page 2
+ {/*
Page 2 */}
+
+
+ Sub-page content:
+
+
+ This is also page 1} />
+ This is page 2} />
+
+
+
+
+ );
+ };
+ return ;
+ },
+});
+
+export const pagesPlugin = createPlugin({
+ id: 'pages',
+ // routes: {
+ // index: indexRouteRef,
+ // // reference in config:
+ // // 'plugin.pages.routes.index'
+ // // OR
+ // // 'page1'
+ // },
+ extensions: [IndexPage, Page1],
+});
diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json
index b1f0097a9d..76a7830707 100644
--- a/packages/frontend-app-api/package.json
+++ b/packages/frontend-app-api/package.json
@@ -33,6 +33,7 @@
],
"dependencies": {
"@backstage/config": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-graphiql": "workspace:^",
"@backstage/types": "workspace:^",
diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx
index e8b429f440..728610679d 100644
--- a/packages/frontend-app-api/src/createApp.tsx
+++ b/packages/frontend-app-api/src/createApp.tsx
@@ -20,7 +20,7 @@ import {
BackstagePlugin,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
-import { RouteExtension } from './extensions/RouteExtension';
+import { CoreRouter } from './extensions/CoreRouter';
import {
createExtensionInstance,
ExtensionInstance,
@@ -30,6 +30,8 @@ import {
mergeExtensionParameters,
readAppExtensionParameters,
} from './wiring/parameters';
+import { RoutingProvider } from './routing/RoutingContext';
+import { RouteRef } from '@backstage/core-plugin-api';
/** @public */
export function createApp(options: { plugins: BackstagePlugin[] }): {
@@ -37,7 +39,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
} {
const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any);
- const builtinExtensions = [RouteExtension];
+ const builtinExtensions = [CoreRouter];
// pull in default extension instance from discovered packages
// apply config to adjust default extension instances and add more
@@ -83,7 +85,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
return existingInstance;
}
- const attachments = Object.fromEntries(
+ const attachments = new Map(
Array.from(
attachmentMap.get(instanceParams.extension.id)?.entries() ?? [],
).map(([inputName, attachmentConfigs]) => [
@@ -92,12 +94,16 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
]),
);
- return createExtensionInstance({
+ const newInstance = createExtensionInstance({
extension: instanceParams.extension,
source: instanceParams.source,
config: instanceParams.config,
attachments,
});
+
+ instances.set(instanceParams.extension.id, newInstance);
+
+ return newInstance;
}
const rootConfigs = attachmentMap.get('root')?.get('default') ?? [];
@@ -105,6 +111,8 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
createInstance(instanceParams),
);
+ const routePaths = extractRouteInfoFromInstanceTree(rootInstances);
+
return {
createRoot() {
const rootComponents = rootInstances.map(
@@ -114,12 +122,49 @@ export function createApp(options: { plugins: BackstagePlugin[] }): {
) as typeof coreExtensionData.reactComponent.T,
);
return (
- <>
+
{rootComponents.map((Component, i) => (
))}
- >
+
);
},
};
}
+
+/** @internal */
+export function extractRouteInfoFromInstanceTree(
+ roots: ExtensionInstance[],
+): Map {
+ const results = new Map();
+
+ function visit(current: ExtensionInstance, basePath: string) {
+ const routePath = current.data.get(coreExtensionData.routePath.id) ?? '';
+ const routeRef = current.data.get(
+ coreExtensionData.routeRef.id,
+ ) as RouteRef;
+
+ // TODO: join paths in a more robust way
+ const fullPath = basePath + routePath;
+ 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}'`,
+ );
+ }
+ results.set(routeRef, fullPath);
+ }
+
+ for (const children of current.attachments.values()) {
+ for (const child of children) {
+ visit(child, fullPath);
+ }
+ }
+ }
+
+ for (const root of roots) {
+ visit(root, '');
+ }
+ return results;
+}
diff --git a/packages/frontend-app-api/src/createExtensionInstance.ts b/packages/frontend-app-api/src/createExtensionInstance.ts
index 911955f50a..a7c49d48cb 100644
--- a/packages/frontend-app-api/src/createExtensionInstance.ts
+++ b/packages/frontend-app-api/src/createExtensionInstance.ts
@@ -19,9 +19,16 @@ import mapValues from 'lodash/mapValues';
/** @internal */
export interface ExtensionInstance {
- id: string;
- data: Map;
- $$type: 'extension-instance';
+ readonly id: string;
+ /**
+ * Maps extension data ref IDs to extensions produced.
+ */
+ readonly data: Map;
+ /**
+ * Maps input names to the actual instances given to them.
+ */
+ readonly attachments: Map;
+ readonly $$type: 'extension-instance';
}
/** @internal */
@@ -29,7 +36,7 @@ export function createExtensionInstance(options: {
extension: Extension;
config: unknown;
source?: BackstagePlugin;
- attachments: Record;
+ attachments: Map;
}): ExtensionInstance {
const { extension, config, source, attachments } = options;
const extensionData = new Map();
@@ -54,7 +61,7 @@ export function createExtensionInstance(options: {
extension.inputs,
({ extensionData: pointData }, inputName) => {
// TODO: validation
- return (attachments[inputName] ?? []).map(attachment =>
+ return (attachments.get(inputName) ?? []).map(attachment =>
mapValues(pointData, ref => attachment.data.get(ref.id)),
);
},
@@ -69,6 +76,7 @@ export function createExtensionInstance(options: {
return {
id: options.extension.id,
data: extensionData,
+ attachments,
$$type: 'extension-instance',
};
}
diff --git a/packages/frontend-app-api/src/extensions/RouteExtension.tsx b/packages/frontend-app-api/src/extensions/CoreRouter.tsx
similarity index 94%
rename from packages/frontend-app-api/src/extensions/RouteExtension.tsx
rename to packages/frontend-app-api/src/extensions/CoreRouter.tsx
index dfa5874d70..f2a760d25e 100644
--- a/packages/frontend-app-api/src/extensions/RouteExtension.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreRouter.tsx
@@ -21,13 +21,14 @@ import {
} from '@backstage/frontend-plugin-api';
import { BrowserRouter, useRoutes } from 'react-router-dom';
-export const RouteExtension = createExtension({
+export const CoreRouter = createExtension({
id: 'core.router',
at: 'root',
inputs: {
routes: {
extensionData: {
path: coreExtensionData.routePath,
+ ref: coreExtensionData.routeRef,
component: coreExtensionData.reactComponent,
},
},
diff --git a/packages/frontend-app-api/src/routing/RoutingContext.tsx b/packages/frontend-app-api/src/routing/RoutingContext.tsx
new file mode 100644
index 0000000000..237c8aa5df
--- /dev/null
+++ b/packages/frontend-app-api/src/routing/RoutingContext.tsx
@@ -0,0 +1,52 @@
+/*
+ * 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 { RouteRef } from '@backstage/core-plugin-api';
+import React, { createContext, ReactNode } from 'react';
+
+export interface RoutingContextType {
+ resolve(
+ routeRef: RouteRef,
+ options: { pathname: string },
+ ): (() => string) | undefined;
+}
+
+export const RoutingContext = createContext({
+ resolve: () => () => '',
+});
+
+export class RouteResolver {
+ constructor(private readonly routePaths: Map) {}
+
+ resolve(anyRouteRef: RouteRef<{}>): (() => string) | undefined {
+ const basePath = this.routePaths.get(anyRouteRef);
+ if (!basePath) {
+ return undefined;
+ }
+ return () => basePath;
+ }
+}
+
+export function RoutingProvider(props: {
+ routePaths: Map;
+ children?: ReactNode;
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 514ca0d82e..a27875bcf8 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -11,6 +11,7 @@ import { ComponentType } from 'react';
import { JsonObject } from '@backstage/types';
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';
@@ -33,6 +34,7 @@ export const coreExtensionData: {
reactComponent: ExtensionDataRef>;
routePath: ExtensionDataRef;
apiFactory: ExtensionDataRef;
+ routeRef: ExtensionDataRef>;
};
// @public (undocumented)
@@ -138,6 +140,7 @@ export function createPageExtension<
at?: string;
disabled?: boolean;
inputs?: TInputs;
+ routeRef?: RouteRef;
component: (props: {
config: TConfig;
inputs: {
@@ -230,4 +233,7 @@ export type PortableSchema = {
parse: (input: unknown) => TOutput;
schema: JsonObject;
};
+
+// @public (undocumented)
+export function useRouteRef(routeRef: RouteRef): () => string;
```
diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json
index fa2b4ff093..ba13135b60 100644
--- a/packages/frontend-plugin-api/package.json
+++ b/packages/frontend-plugin-api/package.json
@@ -32,7 +32,8 @@
"dist"
],
"peerDependencies": {
- "react": "^16.13.1 || ^17.0.0"
+ "react": "^16.13.1 || ^17.0.0",
+ "react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
index b3f8942d0d..fb2d930844 100644
--- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
+++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx
@@ -14,6 +14,7 @@
* limitations under the License.
*/
+import { RouteRef } from '@backstage/core-plugin-api';
import React from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../createSchemaFromZod';
@@ -46,6 +47,7 @@ export function createPageExtension<
at?: string;
disabled?: boolean;
inputs?: TInputs;
+ routeRef?: RouteRef;
component: (props: {
config: TConfig;
inputs: {
@@ -70,6 +72,7 @@ export function createPageExtension<
output: {
component: coreExtensionData.reactComponent,
path: coreExtensionData.routePath,
+ ...(options.routeRef && { routeRef: coreExtensionData.routeRef }),
},
inputs: options.inputs,
configSchema,
@@ -87,6 +90,9 @@ export function createPageExtension<
));
+ if (options.routeRef) {
+ bind.routeRef!(options.routeRef);
+ }
},
});
}
diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts
index 8c87986a14..16f226c483 100644
--- a/packages/frontend-plugin-api/src/index.ts
+++ b/packages/frontend-plugin-api/src/index.ts
@@ -37,3 +37,4 @@ export {
type ExtensionDataValue,
} from './types';
export * from './wiring';
+export * from './routing';
diff --git a/packages/frontend-plugin-api/src/routing/index.ts b/packages/frontend-plugin-api/src/routing/index.ts
new file mode 100644
index 0000000000..4506538fa0
--- /dev/null
+++ b/packages/frontend-plugin-api/src/routing/index.ts
@@ -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 { useRouteRef } from './useRouteRef';
diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.ts b/packages/frontend-plugin-api/src/routing/useRouteRef.ts
new file mode 100644
index 0000000000..ad1c16ea8e
--- /dev/null
+++ b/packages/frontend-plugin-api/src/routing/useRouteRef.ts
@@ -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 { RouteRef } from '@backstage/core-plugin-api';
+// eslint-disable-next-line @backstage/no-forbidden-package-imports
+import { RoutingContext } from '@backstage/frontend-app-api/src/routing/RoutingContext';
+import { useContext, useMemo } from 'react';
+import { useLocation } from 'react-router-dom';
+
+/** @public */
+export function useRouteRef(routeRef: RouteRef): () => string {
+ const { pathname } = useLocation();
+ const resolver = useContext(RoutingContext);
+
+ const routeFunc = useMemo(
+ () => resolver && resolver.resolve(routeRef, { pathname }),
+ [resolver, routeRef, pathname],
+ );
+
+ if (!routeFunc) {
+ throw new Error(`Failed to resolve routeRef ${routeRef}`);
+ }
+
+ return routeFunc;
+}
diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts
index 8c59deabfb..940e821af5 100644
--- a/packages/frontend-plugin-api/src/types.ts
+++ b/packages/frontend-plugin-api/src/types.ts
@@ -15,6 +15,7 @@
*/
import { AnyApiFactory } from '@backstage/core-plugin-api';
+import { RouteRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { PortableSchema } from './createSchemaFromZod';
import { BackstagePlugin } from './wiring';
@@ -37,6 +38,7 @@ export const coreExtensionData = {
reactComponent: createExtensionDataRef('core.reactComponent'),
routePath: createExtensionDataRef('core.routing.path'),
apiFactory: createExtensionDataRef('core.api.factory'),
+ routeRef: createExtensionDataRef('core.routing.ref'),
};
/** @public */
diff --git a/yarn.lock b/yarn.lock
index 73d951f571..3d07ca2d41 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4316,6 +4316,7 @@ __metadata:
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-graphiql": "workspace:^"
"@backstage/types": "workspace:^"
@@ -4342,6 +4343,7 @@ __metadata:
zod-to-json-schema: ^3.21.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
+ react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft