From 3438bdeb7d2210ddc79887777ae4b512ba60b16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 31 Aug 2023 14:32:45 +0200 Subject: [PATCH 1/9] add a skeleton for route test pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Johan Haals Signed-off-by: Fredrik Adelöw --- packages/app-next/src/App.tsx | 3 +- .../app-next/src/examples/pagesPlugin.tsx | 62 +++++++++++++++++++ packages/frontend-app-api/src/createApp.tsx | 6 +- 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/app-next/src/examples/pagesPlugin.tsx 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..4bb0b76ad7 --- /dev/null +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -0,0 +1,62 @@ +/* + * 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, +} from '@backstage/frontend-plugin-api'; + +const IndexPage = createPageExtension({ + id: 'index', + defaultPath: '/', + component: async () => { + const Component = () => { + // const page1 = useRouteRef(); + return ( +
+ op +
+ Page 1 +
+
+ GraphiQL +
+
+ ); + }; + return ; + }, +}); + +const Page1 = createPageExtension({ + id: 'page1', + defaultPath: '/page1', + component: async () => { + const Component = () => ( +
+

This is page 1

+
+ ); + return ; + }, +}); + +export const pagesPlugin = createPlugin({ + id: 'pages', + extensions: [IndexPage, Page1], +}); diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index e8b429f440..b3704fea4f 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -92,12 +92,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') ?? []; From 321eb06b5cb39762fe92b226acee57da5e7788dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 31 Aug 2023 15:53:34 +0200 Subject: [PATCH 2/9] WIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Johan Haals Signed-off-by: Fredrik Adelöw --- packages/app-next/app-config.yaml | 235 ++++++++++-------- .../app-next/src/examples/graphiqlPlugin.tsx | 1 + .../app-next/src/examples/pagesPlugin.tsx | 51 +++- .../src/routing/RoutingContext.ts | 25 ++ packages/frontend-plugin-api/package.json | 3 +- .../src/extensions/createPageExtension.tsx | 6 + .../src/routing/useRouteRef.ts | 32 +++ packages/frontend-plugin-api/src/types.ts | 2 + 8 files changed, 249 insertions(+), 106 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RoutingContext.ts create mode 100644 packages/frontend-plugin-api/src/routing/useRouteRef.ts 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/examples/graphiqlPlugin.tsx b/packages/app-next/src/examples/graphiqlPlugin.tsx index 4e3768dafa..e80e137ae5 100644 --- a/packages/app-next/src/examples/graphiqlPlugin.tsx +++ b/packages/app-next/src/examples/graphiqlPlugin.tsx @@ -23,6 +23,7 @@ import React from 'react'; export const GraphiqlPage = createPageExtension({ id: 'graphiql.page', defaultPath: '/graphiql', + disabled: true, component: () => import('@backstage/plugin-graphiql').then(({ Router }) => ), }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index 4bb0b76ad7..4551f0c8f7 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -19,19 +19,31 @@ import { Link } from '@backstage/core-components'; import { createPageExtension, createPlugin, + useRouteRef, } from '@backstage/frontend-plugin-api'; +import { createRouteRef, createSubRouteRef } 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: '/', + // indexRouteRef component: async () => { const Component = () => { - // const page1 = useRouteRef(); + const page1Link = useRouteRef(page1RouteRef); return (
op
- Page 1 + Page 1
GraphiQL @@ -46,17 +58,42 @@ const IndexPage = createPageExtension({ const Page1 = createPageExtension({ id: 'page1', defaultPath: '/page1', + routeRef: page1RouteRef, component: async () => { - const Component = () => ( -
-

This is page 1

-
- ); + const Component = () => { + const indexLink = useRouteRef(indexRouteRef); + // const page2Link = useRouteRef(page2RouteRef); + + return ( +
+

This is page 1

+ Go back + {/* 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/src/routing/RoutingContext.ts b/packages/frontend-app-api/src/routing/RoutingContext.ts new file mode 100644 index 0000000000..968cb82d5f --- /dev/null +++ b/packages/frontend-app-api/src/routing/RoutingContext.ts @@ -0,0 +1,25 @@ +/* + * 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 { createContext } from 'react'; + +export interface RoutingContextType { + resolve(routeRef: RouteRef, options: { pathname: string }): () => string; +} + +export const RoutingContext = createContext({ + resolve: () => () => '', +}); 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/routing/useRouteRef.ts b/packages/frontend-plugin-api/src/routing/useRouteRef.ts new file mode 100644 index 0000000000..f828240966 --- /dev/null +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.ts @@ -0,0 +1,32 @@ +/* + * 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 { RoutingContext } from '@backstage/frontend-app-api/src/routing/RoutingContext'; +import { useContext, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +export function useRouteRef(routeRef: RouteRef): () => string | undefined { + const { pathname } = useLocation(); + const resolver = useContext(RoutingContext); + + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, { pathname }), + [resolver, routeRef, pathname], + ); + + 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 */ From 91a534142deaea27908d4bf6d63f811bc2c71625 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 1 Sep 2023 15:04:18 +0200 Subject: [PATCH 3/9] frontend-app-api: store attachemnts on extension instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Philipp Hugenroth Co-authored-by: Johan Haals Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/src/createApp.tsx | 2 +- .../src/createExtensionInstance.ts | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index b3704fea4f..192e2485c6 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -83,7 +83,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]) => [ 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', }; } From 4d410e0985a5511be13bf597f02ad9ff299bf185 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 1 Sep 2023 15:05:18 +0200 Subject: [PATCH 4/9] frontend-app-api: initial routing system implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Philipp Hugenroth Co-authored-by: Johan Haals Signed-off-by: Patrik Oldsberg --- .../app-next/src/examples/graphiqlPlugin.tsx | 1 - .../app-next/src/examples/pagesPlugin.tsx | 4 +- packages/frontend-app-api/package.json | 1 + packages/frontend-app-api/src/createApp.tsx | 38 +++++++++++++- .../src/extensions/RouteExtension.tsx | 1 + .../src/routing/RoutingContext.tsx | 52 +++++++++++++++++++ packages/frontend-plugin-api/src/index.ts | 1 + .../src/routing/index.ts} | 10 +--- .../src/routing/useRouteRef.ts | 7 ++- yarn.lock | 2 + 10 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 packages/frontend-app-api/src/routing/RoutingContext.tsx rename packages/{frontend-app-api/src/routing/RoutingContext.ts => frontend-plugin-api/src/routing/index.ts} (66%) diff --git a/packages/app-next/src/examples/graphiqlPlugin.tsx b/packages/app-next/src/examples/graphiqlPlugin.tsx index e80e137ae5..4e3768dafa 100644 --- a/packages/app-next/src/examples/graphiqlPlugin.tsx +++ b/packages/app-next/src/examples/graphiqlPlugin.tsx @@ -23,7 +23,6 @@ import React from 'react'; export const GraphiqlPage = createPageExtension({ id: 'graphiql.page', defaultPath: '/graphiql', - disabled: true, component: () => import('@backstage/plugin-graphiql').then(({ Router }) => ), }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index 4551f0c8f7..4bf41b772a 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -21,7 +21,7 @@ import { createPlugin, useRouteRef, } from '@backstage/frontend-plugin-api'; -import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; +import { createRouteRef } from '@backstage/core-plugin-api'; import { Route, Routes } from 'react-router-dom'; const indexRouteRef = createRouteRef({ id: 'index' }); @@ -35,7 +35,7 @@ const page1RouteRef = createRouteRef({ id: 'page1' }); const IndexPage = createPageExtension({ id: 'index', defaultPath: '/', - // indexRouteRef + routeRef: indexRouteRef, component: async () => { const Component = () => { const page1Link = useRouteRef(page1RouteRef); 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 192e2485c6..a52b5c95d7 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -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[] }): { @@ -109,6 +111,8 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { createInstance(instanceParams), ); + const routePaths = extractRouteInfoFromInstanceTree(rootInstances); + return { createRoot() { const rootComponents = rootInstances.map( @@ -118,12 +122,42 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { ) as typeof coreExtensionData.reactComponent.T, ); return ( - <> + {rootComponents.map((Component, i) => ( ))} - + ); }, }; } + +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) { + 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/extensions/RouteExtension.tsx b/packages/frontend-app-api/src/extensions/RouteExtension.tsx index dfa5874d70..ffbe802413 100644 --- a/packages/frontend-app-api/src/extensions/RouteExtension.tsx +++ b/packages/frontend-app-api/src/extensions/RouteExtension.tsx @@ -28,6 +28,7 @@ export const RouteExtension = createExtension({ 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/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-app-api/src/routing/RoutingContext.ts b/packages/frontend-plugin-api/src/routing/index.ts similarity index 66% rename from packages/frontend-app-api/src/routing/RoutingContext.ts rename to packages/frontend-plugin-api/src/routing/index.ts index 968cb82d5f..4506538fa0 100644 --- a/packages/frontend-app-api/src/routing/RoutingContext.ts +++ b/packages/frontend-plugin-api/src/routing/index.ts @@ -13,13 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { RouteRef } from '@backstage/core-plugin-api'; -import { createContext } from 'react'; -export interface RoutingContextType { - resolve(routeRef: RouteRef, options: { pathname: string }): () => string; -} - -export const RoutingContext = createContext({ - resolve: () => () => '', -}); +export { useRouteRef } from './useRouteRef'; diff --git a/packages/frontend-plugin-api/src/routing/useRouteRef.ts b/packages/frontend-plugin-api/src/routing/useRouteRef.ts index f828240966..fa50522bca 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.ts @@ -15,11 +15,12 @@ */ 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'; -export function useRouteRef(routeRef: RouteRef): () => string | undefined { +export function useRouteRef(routeRef: RouteRef): () => string { const { pathname } = useLocation(); const resolver = useContext(RoutingContext); @@ -28,5 +29,9 @@ export function useRouteRef(routeRef: RouteRef): () => string | undefined { [resolver, routeRef, pathname], ); + if (!routeFunc) { + throw new Error(`Failed to resolve routeRef ${routeRef}`); + } + return routeFunc; } diff --git a/yarn.lock b/yarn.lock index 2e94de61cb..472549f1f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4311,6 +4311,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:^" @@ -4337,6 +4338,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 From 6d2c9331761029eb2033602d49692b2dfd9addf6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 1 Sep 2023 15:17:21 +0200 Subject: [PATCH 5/9] frontend-app-api: RouteExtension -> CoreRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/src/createApp.tsx | 4 ++-- .../src/extensions/{RouteExtension.tsx => CoreRouter.tsx} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/frontend-app-api/src/extensions/{RouteExtension.tsx => CoreRouter.tsx} (96%) diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index a52b5c95d7..996abf99ad 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, @@ -39,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 diff --git a/packages/frontend-app-api/src/extensions/RouteExtension.tsx b/packages/frontend-app-api/src/extensions/CoreRouter.tsx similarity index 96% rename from packages/frontend-app-api/src/extensions/RouteExtension.tsx rename to packages/frontend-app-api/src/extensions/CoreRouter.tsx index ffbe802413..f2a760d25e 100644 --- a/packages/frontend-app-api/src/extensions/RouteExtension.tsx +++ b/packages/frontend-app-api/src/extensions/CoreRouter.tsx @@ -21,7 +21,7 @@ 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: { From 0820925dc59b24053b7ac237ec16557761b2ca8b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Sep 2023 13:31:51 +0200 Subject: [PATCH 6/9] frontend-app-api: validate route ref IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Camila Belo Co-authored-by: Philipp Hugenroth Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/src/createApp.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index 996abf99ad..200e007758 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -146,6 +146,12 @@ export function extractRouteInfoFromInstanceTree( // 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); } From b0526feb7013199a8d28a36c7252d99566417eaf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Sep 2023 13:48:59 +0200 Subject: [PATCH 7/9] app-next: page2 link Signed-off-by: Patrik Oldsberg --- packages/app-next/src/examples/pagesPlugin.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index 4bf41b772a..6616c88c4b 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -68,6 +68,7 @@ const Page1 = createPageExtension({

This is page 1

Go back + Page 2 {/* Page 2 */}
From 7e59c93b104b8a1eb085a1d3f19d0d70cab44e0b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 4 Sep 2023 15:05:54 +0200 Subject: [PATCH 8/9] frontend-plugin-api: update API report Signed-off-by: Patrik Oldsberg --- packages/frontend-plugin-api/api-report.md | 6 ++++++ packages/frontend-plugin-api/src/routing/useRouteRef.ts | 1 + 2 files changed, 7 insertions(+) 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/src/routing/useRouteRef.ts b/packages/frontend-plugin-api/src/routing/useRouteRef.ts index fa50522bca..ad1c16ea8e 100644 --- a/packages/frontend-plugin-api/src/routing/useRouteRef.ts +++ b/packages/frontend-plugin-api/src/routing/useRouteRef.ts @@ -20,6 +20,7 @@ import { RoutingContext } from '@backstage/frontend-app-api/src/routing/RoutingC 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); From 34b6aac2b44062231f127fcaed59d9b79ab4e6f6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 5 Sep 2023 10:45:58 +0200 Subject: [PATCH 9/9] frontend-app-api: fix internal conflict Signed-off-by: Patrik Oldsberg --- packages/frontend-app-api/src/createApp.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index 200e007758..728610679d 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -132,6 +132,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { }; } +/** @internal */ export function extractRouteInfoFromInstanceTree( roots: ExtensionInstance[], ): Map {