diff --git a/.changeset/metal-boxes-laugh.md b/.changeset/metal-boxes-laugh.md new file mode 100644 index 0000000000..9a5ab24e0e --- /dev/null +++ b/.changeset/metal-boxes-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +The `useApp` and `useRouteRef` functions are now forwards compatible with the new frontend system. Along with the previous route reference changes this means that there is no longer a need to use `compatWrapper` from `@backstage/core-compat-api` to make code based on `@backstage/core-plugin-api` compatible with `@backstage/frontend-plugin-api` APIs. diff --git a/packages/core-plugin-api/src/app/useApp.test.tsx b/packages/core-plugin-api/src/app/useApp.test.tsx index 34c956138e..2cee7f6f3c 100644 --- a/packages/core-plugin-api/src/app/useApp.test.tsx +++ b/packages/core-plugin-api/src/app/useApp.test.tsx @@ -15,20 +15,129 @@ */ import { renderHook } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; import { createVersionedContextForTesting } from '@backstage/version-bridge'; +import { + appTreeApiRef, + iconsApiRef, + AppTreeApi, + IconsApi, + AppTree, + AppNode, +} from '@backstage/frontend-plugin-api'; import { useApp } from './useApp'; +import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; -describe('v1 consumer', () => { - const context = createVersionedContextForTesting('app-context'); +describe('useApp', () => { + describe('old system', () => { + const context = createVersionedContextForTesting('app-context'); - afterEach(() => { - context.reset(); + afterEach(() => { + context.reset(); + }); + + it('should provide an app context', () => { + const wrapper = ({ children }: PropsWithChildren<{}>) => ( + {children} + ); + context.set({ 1: 'context-value' }); + + const renderedHook = renderHook(() => useApp(), { wrapper }); + expect(renderedHook.result.current).toBe('context-value'); + }); }); - it('should provide an app context', () => { - context.set({ 1: 'context-value' }); + describe('new system', () => { + const mockIcon = () => null; + const mockIconsApi: IconsApi = { + getIcon: jest.fn((key: string) => + key === 'test-icon' ? mockIcon : undefined, + ), + listIconKeys: jest.fn(() => ['test-icon']), + }; - const renderedHook = renderHook(() => useApp()); - expect(renderedHook.result.current).toBe('context-value'); + const mockPlugin = { + id: 'test-plugin', + }; + + const mockAppNode: AppNode = { + spec: { + id: 'test-node', + attachTo: { id: 'root', input: 'children' }, + extension: {} as any, + disabled: false, + plugin: mockPlugin as any, + }, + edges: { + attachments: new Map(), + }, + }; + + const mockAppTree: AppTree = { + root: mockAppNode, + nodes: new Map([['test-node', mockAppNode]]), + orphans: [], + }; + + const mockAppTreeApi: AppTreeApi = { + getTree: jest.fn(() => ({ tree: mockAppTree })), + getNodesByRoutePath: jest.fn(() => ({ nodes: [] })), + }; + + it('should provide an app context from new app system', () => { + const wrapper = ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ); + + const renderedHook = renderHook(() => useApp(), { wrapper }); + + const appContext = renderedHook.result.current; + expect(appContext).toBeDefined(); + expect(appContext.getPlugins()).toHaveLength(1); + expect(appContext.getPlugins()[0].getId()).toBe('test-plugin'); + expect(appContext.getSystemIcon('test-icon')).toBe(mockIcon); + expect(appContext.getSystemIcons()).toEqual({ 'test-icon': mockIcon }); + expect(appContext.getComponents().Progress).toBeDefined(); + expect(appContext.getComponents().NotFoundErrorPage).toBeDefined(); + }); + + it('should error on missing appTreeApi or iconsApi', () => { + withLogCollector(['error'], () => { + expect(() => + renderHook(() => useApp(), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + {children} + ), + }), + ).toThrow('App context is not available'); + + expect(() => + renderHook(() => useApp(), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }), + ).toThrow('App context is not available'); + + expect(() => + renderHook(() => useApp(), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ), + }), + ).toThrow('App context is not available'); + }); + }); }); }); diff --git a/packages/core-plugin-api/src/app/useApp.tsx b/packages/core-plugin-api/src/app/useApp.tsx index 05723ee387..8368ed955f 100644 --- a/packages/core-plugin-api/src/app/useApp.tsx +++ b/packages/core-plugin-api/src/app/useApp.tsx @@ -14,18 +14,150 @@ * limitations under the License. */ +import { useMemo } from 'react'; import { useVersionedContext } from '@backstage/version-bridge'; +import { + appTreeApiRef, + iconsApiRef, + useApiHolder, + ErrorDisplay, + NotFoundErrorPage, + Progress, + createFrontendPlugin, + FrontendPlugin, +} from '@backstage/frontend-plugin-api'; +import { + AppComponents, + IconComponent, + BackstagePlugin, +} from '@backstage/core-plugin-api'; +import { getOrCreateGlobalSingleton } from '@backstage/version-bridge'; import { AppContext as AppContextV1 } from './types'; +const legacyPluginStore = getOrCreateGlobalSingleton( + 'legacy-plugin-compatibility-store', + () => new WeakMap(), +); + +function toLegacyPlugin(plugin: FrontendPlugin): BackstagePlugin { + let legacy = legacyPluginStore.get(plugin); + if (legacy) { + return legacy; + } + + const errorMsg = 'Not implemented in legacy plugin compatibility layer'; + const notImplemented = () => { + throw new Error(errorMsg); + }; + + legacy = { + getId(): string { + return plugin.id; + }, + get routes() { + return {}; + }, + get externalRoutes() { + return {}; + }, + getApis: notImplemented, + getFeatureFlags: notImplemented, + provide: notImplemented, + }; + + legacyPluginStore.set(plugin, legacy); + return legacy; +} + +function toNewPlugin(plugin: BackstagePlugin): FrontendPlugin { + return createFrontendPlugin({ + pluginId: plugin.getId(), + }); +} + /** * React hook providing {@link AppContext}. * * @public */ export const useApp = (): AppContextV1 => { + const apiHolder = useApiHolder(); + const appTreeApi = apiHolder.get(appTreeApiRef); const versionedContext = useVersionedContext<{ 1: AppContextV1 }>( 'app-context', ); + + const newAppContext = useMemo(() => { + if (!appTreeApi) { + return null; + } + + const iconsApi = apiHolder.get(iconsApiRef); + if (!iconsApi) { + return null; + } + + const { tree } = appTreeApi.getTree(); + + let gatheredPlugins: BackstagePlugin[] | undefined = undefined; + + const ErrorBoundaryFallbackWrapper: AppComponents['ErrorBoundaryFallback'] = + ({ plugin, ...rest }) => ( + + ); + + return { + getPlugins(): BackstagePlugin[] { + if (gatheredPlugins) { + return gatheredPlugins; + } + + const pluginSet = new Set(); + for (const node of tree.nodes.values()) { + const plugin = node.spec.plugin; + if (plugin) { + pluginSet.add(toLegacyPlugin(plugin)); + } + } + gatheredPlugins = Array.from(pluginSet); + + return gatheredPlugins; + }, + + getSystemIcon(key: string): IconComponent | undefined { + return iconsApi.getIcon(key); + }, + + getSystemIcons(): Record { + return Object.fromEntries( + iconsApi.listIconKeys().map(key => [key, iconsApi.getIcon(key)!]), + ); + }, + + getComponents(): AppComponents { + return { + NotFoundErrorPage: NotFoundErrorPage, + BootErrorPage() { + throw new Error( + 'The BootErrorPage app component should not be accessed by plugins', + ); + }, + Progress: Progress, + Router() { + throw new Error( + 'The Router app component should not be accessed by plugins', + ); + }, + ErrorBoundaryFallback: ErrorBoundaryFallbackWrapper, + }; + }, + }; + }, [appTreeApi, apiHolder]); + + if (newAppContext) { + return newAppContext; + } + if (!versionedContext) { throw new Error('App context is not available'); } diff --git a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx index b96fc5fc23..325690b6eb 100644 --- a/packages/core-plugin-api/src/routing/useRouteRef.test.tsx +++ b/packages/core-plugin-api/src/routing/useRouteRef.test.tsx @@ -18,140 +18,203 @@ import { renderHook } from '@testing-library/react'; import { PropsWithChildren } from 'react'; import { MemoryRouter, Router } from 'react-router-dom'; import { createVersionedContextForTesting } from '@backstage/version-bridge'; +import { + routeResolutionApiRef, + RouteResolutionApi, + RouteFunc, +} from '@backstage/frontend-plugin-api'; import { useRouteRef } from './useRouteRef'; import { createRouteRef } from './RouteRef'; +import { createExternalRouteRef } from './ExternalRouteRef'; import { createBrowserHistory } from 'history'; +import { TestApiProvider } from '@backstage/test-utils'; -describe('v1 consumer', () => { - const context = createVersionedContextForTesting('routing-context'); +describe('useRouteRef', () => { + describe('old app system', () => { + const context = createVersionedContextForTesting('routing-context'); - afterEach(() => { - context.reset(); - }); - - it('should resolve routes', () => { - const resolve = jest.fn(() => () => '/hello'); - context.set({ 1: { resolve } }); - - const routeRef = createRouteRef({ id: 'ref1' }); - - const renderedHook = renderHook(() => useRouteRef(routeRef), { - wrapper: ({ children }: PropsWithChildren<{}>) => ( - - ), + afterEach(() => { + context.reset(); }); - const routeFunc = renderedHook.result.current; - expect(routeFunc()).toBe('/hello'); - expect(resolve).toHaveBeenCalledWith( - routeRef, - expect.objectContaining({ - pathname: '/my-page', - }), - ); - }); + it('should resolve routes', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); - it('re-resolves the routeFunc when the search parameters change', () => { - const resolve = jest.fn(() => () => '/hello'); - context.set({ 1: { resolve } }); + const routeRef = createRouteRef({ id: 'ref1' }); - const routeRef = createRouteRef({ id: 'ref1' }); - const history = createBrowserHistory(); - history.push('/my-page'); + const renderedHook = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + ), + }); - const { rerender } = renderHook(() => useRouteRef(routeRef), { - wrapper: ({ children }: PropsWithChildren<{}>) => ( - - ), + const routeFunc = renderedHook.result.current; + expect(routeFunc()).toBe('/hello'); + expect(resolve).toHaveBeenCalledWith( + routeRef, + expect.objectContaining({ + pathname: '/my-page', + }), + ); }); - expect(resolve).toHaveBeenCalledTimes(1); + it('re-resolves the routeFunc when the search parameters change', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); - history.push('/my-new-page'); - rerender(); + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); - expect(resolve).toHaveBeenCalledTimes(2); - }); + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + ), + }); - it('does not re-resolve the routeFunc the location pathname does not change', () => { - const resolve = jest.fn(() => () => '/hello'); - context.set({ 1: { resolve } }); + expect(resolve).toHaveBeenCalledTimes(1); - const routeRef = createRouteRef({ id: 'ref1' }); - const history = createBrowserHistory(); - history.push('/my-page'); + history.push('/my-new-page'); + rerender(); - const { rerender } = renderHook(() => useRouteRef(routeRef), { - wrapper: ({ children }: PropsWithChildren<{}>) => ( - - ), + expect(resolve).toHaveBeenCalledTimes(2); }); - expect(resolve).toHaveBeenCalledTimes(1); + it('does not re-resolve the routeFunc the location pathname does not change', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); - history.push('/my-page'); - rerender(); + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); - expect(resolve).toHaveBeenCalledTimes(1); - }); + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + ), + }); - it('does not re-resolve the routeFunc when the search parameter changes', () => { - const resolve = jest.fn(() => () => '/hello'); - context.set({ 1: { resolve } }); + expect(resolve).toHaveBeenCalledTimes(1); - const routeRef = createRouteRef({ id: 'ref1' }); - const history = createBrowserHistory(); - history.push('/my-page'); + history.push('/my-page'); + rerender(); - const { rerender } = renderHook(() => useRouteRef(routeRef), { - wrapper: ({ children }: PropsWithChildren<{}>) => ( - - ), + expect(resolve).toHaveBeenCalledTimes(1); }); - 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 } }); - history.push('/my-page?foo=bar'); - rerender(); + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); - expect(resolve).toHaveBeenCalledTimes(1); - }); + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + ), + }); - it('does not re-resolve the routeFunc when the hash parameter changes', () => { - const resolve = jest.fn(() => () => '/hello'); - context.set({ 1: { resolve } }); + expect(resolve).toHaveBeenCalledTimes(1); - const routeRef = createRouteRef({ id: 'ref1' }); - const history = createBrowserHistory(); - history.push('/my-page'); + history.push('/my-page?foo=bar'); + rerender(); - const { rerender } = renderHook(() => useRouteRef(routeRef), { - wrapper: ({ children }: PropsWithChildren<{}>) => ( - - ), + expect(resolve).toHaveBeenCalledTimes(1); }); - 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 } }); - history.push('/my-page#foo'); - rerender(); + const routeRef = createRouteRef({ id: 'ref1' }); + const history = createBrowserHistory(); + history.push('/my-page'); - expect(resolve).toHaveBeenCalledTimes(1); + const { rerender } = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }: PropsWithChildren<{}>) => ( + + ), + }); + + expect(resolve).toHaveBeenCalledTimes(1); + + history.push('/my-page#foo'); + rerender(); + + expect(resolve).toHaveBeenCalledTimes(1); + }); + }); + + describe('new app system', () => { + it('should resolve routes using routeResolutionApi', () => { + const routeRef = createRouteRef({ id: 'ref1' }); + const mockRouteFunc: RouteFunc = jest.fn(() => '/new-route'); + + const mockRouteResolutionApi: RouteResolutionApi = { + resolve: jest.fn(() => mockRouteFunc), + }; + + const wrapper = ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ); + + const renderedHook = renderHook(() => useRouteRef(routeRef), { wrapper }); + + const routeFunc = renderedHook.result.current; + expect(routeFunc).toBe(mockRouteFunc); + expect(mockRouteResolutionApi.resolve).toHaveBeenCalledWith( + expect.anything(), + { sourcePath: '/my-page' }, + ); + expect(routeFunc()).toBe('/new-route'); + }); + + it('should handle optional external route refs', () => { + const externalRouteRef = createExternalRouteRef({ + id: 'external-ref', + optional: true, + }); + + const mockRouteResolutionApi: RouteResolutionApi = { + resolve: jest.fn(() => undefined), + }; + + const wrapper = ({ children }: PropsWithChildren<{}>) => ( + + {children} + + ); + + const renderedHook = renderHook(() => useRouteRef(externalRouteRef), { + wrapper, + }); + + expect(renderedHook.result.current).toBeUndefined(); + }); }); }); diff --git a/packages/core-plugin-api/src/routing/useRouteRef.tsx b/packages/core-plugin-api/src/routing/useRouteRef.tsx index cf292b110f..a46e96ba9e 100644 --- a/packages/core-plugin-api/src/routing/useRouteRef.tsx +++ b/packages/core-plugin-api/src/routing/useRouteRef.tsx @@ -17,6 +17,10 @@ import { useMemo } from 'react'; import { matchRoutes, useLocation } from 'react-router-dom'; import { useVersionedContext } from '@backstage/version-bridge'; +import { + routeResolutionApiRef, + useApiHolder, +} from '@backstage/frontend-plugin-api'; import { AnyParams, ExternalRouteRef, @@ -86,30 +90,53 @@ export function useRouteRef( | ExternalRouteRef, ): RouteFunc | undefined { const { pathname } = useLocation(); + const apiHolder = useApiHolder(); + const routeResolutionApi = apiHolder.get(routeResolutionApiRef); 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( + const resolver = versionedContext?.atVersion(1); + + const newRouteFunc = useMemo(() => { + if (!routeResolutionApi) { + return null; + } + + try { + return routeResolutionApi.resolve(routeRef, { + sourcePath: pathname, + }); + } catch { + return null; + } + }, [routeResolutionApi, routeRef, pathname]); + + const legacyRouteFunc = useMemo( () => resolver && resolver.resolve(routeRef, { pathname }), [resolver, routeRef, pathname], ); - if (!versionedContext) { - throw new Error('useRouteRef used outside of routing context'); + if (newRouteFunc !== null) { + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!newRouteFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + return newRouteFunc; } + + if (!versionedContext) { + throw new Error('Routing context is not available'); + } + if (!resolver) { throw new Error('RoutingContext v1 not available'); } const isOptional = 'optional' in routeRef && routeRef.optional; - if (!routeFunc && !isOptional) { + if (!legacyRouteFunc && !isOptional) { throw new Error(`No path for ${routeRef}`); } - return routeFunc; + return legacyRouteFunc; }