diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx index 004bfab4c3..278eaa9a27 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx @@ -14,25 +14,15 @@ * limitations under the License. */ -import { - Children, - ReactElement, - ReactNode, - useEffect, - useMemo, - useRef, -} from 'react'; -import { useOutlet, useNavigate } from 'react-router-dom'; -import { Page } from '@backstage/core-components'; +import { Children, ReactElement, ReactNode, useMemo } from 'react'; +import { useOutlet } from 'react-router-dom'; +import { Page, Progress } from '@backstage/core-components'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { TECHDOCS_ADDONS_KEY, TECHDOCS_ADDONS_WRAPPER_KEY, TechDocsReaderPageProvider, - buildTechDocsURL, } from '@backstage/plugin-techdocs-react'; -import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; -import useAsync from 'react-use/esm/useAsync'; import { TechDocsReaderPageRenderFunction } from '../../../types'; import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent'; import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader'; @@ -41,11 +31,8 @@ import { rootDocsRouteRef } from '../../../routes'; import { getComponentData, useRouteRefParams, - useApi, - useRouteRef, } from '@backstage/core-plugin-api'; import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { createTheme, styled, @@ -53,7 +40,7 @@ import { ThemeProvider, useTheme, } from '@material-ui/core/styles'; -import { Progress } from '@backstage/core-components'; +import { useExternalRedirect } from './useExternalRedirect'; /* An explanation for the multiple ways of customizing the TechDocs reader page @@ -201,10 +188,6 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { const outlet = useOutlet(); - const catalogApi = useApi(catalogApiRef); - const navigate = useNavigate(); - const viewTechdocLink = useRouteRef(rootDocsRouteRef); - const memoizedEntityRef = useMemo( () => ({ kind: entityRef.kind, @@ -214,56 +197,8 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { [entityRef.kind, entityRef.name, entityRef.namespace], ); - // Create a stable string key for the entity to use as a dependency. - // This ensures we only check for external redirects when the entity changes, - // not on every sub-page navigation within the same entity's TechDocs. - const entityKey = useMemo( - () => - `${memoizedEntityRef.kind}:${memoizedEntityRef.namespace}/${memoizedEntityRef.name}`, - [ - memoizedEntityRef.kind, - memoizedEntityRef.namespace, - memoizedEntityRef.name, - ], - ); - - // Track which entity we've already checked to avoid redundant checks - // when navigating between pages within the same entity's documentation. - const checkedEntityRef = useRef(null); - const shouldCheckForRedirect = checkedEntityRef.current !== entityKey; - - // Check if this entity should redirect to external TechDocs - const externalRedirectResult = useAsync(async () => { - try { - const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef); - - if ( - catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] - ) { - return buildTechDocsURL(catalogEntity, viewTechdocLink); - } - } catch (error) { - // Ignore errors and allow the current entity's TechDocs to load. - // This handles cases where the catalog API is unavailable or the entity doesn't exist. - } - - return undefined; - }, [entityKey, catalogApi]); - - // Navigate to external TechDocs if a redirect URL is available - useEffect(() => { - if (!externalRedirectResult.loading && externalRedirectResult.value) { - navigate(externalRedirectResult.value, { replace: true }); - } - }, [externalRedirectResult.loading, externalRedirectResult.value, navigate]); - - // Mark entity as checked once we've determined there's no redirect needed. - // This prevents showing loading states on subsequent sub-page navigation. - useEffect(() => { - if (!externalRedirectResult.loading && !externalRedirectResult.value) { - checkedEntityRef.current = entityKey; - } - }, [externalRedirectResult.loading, externalRedirectResult.value, entityKey]); + // Check for external TechDocs redirects and handle navigation + const { shouldShowProgress } = useExternalRedirect(memoizedEntityRef); const page: ReactNode = useMemo(() => { if (children) { @@ -284,11 +219,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => { }, [children, outlet]); // Show loading indicator when checking for external redirects or about to redirect. - // Only check on first visit to an entity to prevent flashing during sub-page navigation. - if ( - (shouldCheckForRedirect && externalRedirectResult.loading) || - externalRedirectResult.value - ) { + if (shouldShowProgress) { return ; } diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.test.tsx new file mode 100644 index 0000000000..e7d41f6ff9 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.test.tsx @@ -0,0 +1,282 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { useExternalRedirect } from './useExternalRedirect'; +import { TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; + +const mockNavigate = jest.fn(); +const mockViewTechdocLink = jest.fn(() => '/docs'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useRouteRef: () => mockViewTechdocLink, +})); + +describe('useExternalRedirect', () => { + const mockCatalogApi = { + getEntityByRef: jest.fn(), + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not show progress when entity has no external annotation', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + }, + }); + + const { result } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.shouldShowProgress).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should navigate when entity has external annotation', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + annotations: { + [TECHDOCS_EXTERNAL_ANNOTATION]: 'component:external/docs', + }, + }, + }); + + const { result } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.shouldShowProgress).toBe(true); + expect(mockNavigate).toHaveBeenCalledWith(expect.any(String), { + replace: true, + }); + }); + + it('should handle catalog API errors gracefully', async () => { + mockCatalogApi.getEntityByRef.mockRejectedValue( + new Error('Catalog API error'), + ); + + const { result } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.shouldShowProgress).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should not re-check when entity key stays the same', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + }, + }); + + const { rerender } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledTimes(1); + }); + + // Rerender with the same entity - should not trigger another API call + rerender(); + + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledTimes(1); + }); + + it('should handle deep linking with techdocs-entity-path annotation', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + annotations: { + 'backstage.io/techdocs-entity': 'component:external/docs', + 'backstage.io/techdocs-entity-path': '/inner-component-docs', + }, + }, + }); + + const { result } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Should navigate with the path appended (handled by buildTechDocsURL) + expect(mockNavigate).toHaveBeenCalledWith(expect.any(String), { + replace: true, + }); + expect(result.current.shouldShowProgress).toBe(true); + }); + + it('should handle empty external annotation value', async () => { + mockCatalogApi.getEntityByRef.mockResolvedValue({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + namespace: 'default', + annotations: { + 'backstage.io/techdocs-entity': '', + }, + }, + }); + + const { result } = renderHook( + () => + useExternalRedirect({ + kind: 'Component', + name: 'test-component', + namespace: 'default', + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Empty annotation should be treated as no redirect + expect(result.current.shouldShowProgress).toBe(false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('should re-check when entity changes', async () => { + mockCatalogApi.getEntityByRef + .mockResolvedValueOnce({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'first-component', + namespace: 'default', + }, + }) + .mockResolvedValueOnce({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'second-component', + namespace: 'default', + }, + }); + + const { rerender } = renderHook( + ({ entityRef }) => useExternalRedirect(entityRef), + { + wrapper, + initialProps: { + entityRef: { + kind: 'Component', + name: 'first-component', + namespace: 'default', + }, + }, + }, + ); + + await waitFor(() => { + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledTimes(1); + }); + + // Change to a different entity + rerender({ + entityRef: { + kind: 'Component', + name: 'second-component', + namespace: 'default', + }, + }); + + await waitFor(() => { + expect(mockCatalogApi.getEntityByRef).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.ts b/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.ts new file mode 100644 index 0000000000..17e292534b --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/useExternalRedirect.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useAsync from 'react-use/esm/useAsync'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { CompoundEntityRef } from '@backstage/catalog-model'; +import { buildTechDocsURL } from '@backstage/plugin-techdocs-react'; +import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; +import { rootDocsRouteRef } from '../../../routes'; + +/** + * Hook to handle external TechDocs redirects based on entity annotations. + * Checks if an entity has the `backstage.io/techdocs-entity` annotation and + * redirects to the external TechDocs URL if present. + * + * @param entityRef - The entity reference to check for external redirects + * @returns Object containing loading state and whether a redirect is in progress + * + * @internal + */ +export function useExternalRedirect(entityRef: CompoundEntityRef): { + loading: boolean; + shouldShowProgress: boolean; +} { + const catalogApi = useApi(catalogApiRef); + const navigate = useNavigate(); + const viewTechdocLink = useRouteRef(rootDocsRouteRef); + + // Create a stable string key for the entity to use as a dependency. + // This ensures we only check for external redirects when the entity changes, + // not on every sub-page navigation within the same entity's TechDocs. + const entityKey = useMemo( + () => `${entityRef.kind}:${entityRef.namespace}/${entityRef.name}`, + [entityRef.kind, entityRef.namespace, entityRef.name], + ); + + // Track which entity we've already checked to avoid redundant checks + // when navigating between pages within the same entity's documentation. + const checkedEntityRef = useRef(null); + const shouldCheckForRedirect = checkedEntityRef.current !== entityKey; + + // Check if this entity should redirect to external TechDocs + const externalRedirectResult = useAsync(async () => { + try { + const catalogEntity = await catalogApi.getEntityByRef(entityRef); + + if ( + catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] + ) { + return buildTechDocsURL(catalogEntity, viewTechdocLink); + } + } catch (error) { + // Ignore errors and allow the current entity's TechDocs to load. + // This handles cases where the catalog API is unavailable or the entity doesn't exist. + } + + return undefined; + }, [entityKey, catalogApi]); + + // Navigate to external TechDocs if a redirect URL is available + useEffect(() => { + if (!externalRedirectResult.loading && externalRedirectResult.value) { + navigate(externalRedirectResult.value, { replace: true }); + } + }, [externalRedirectResult.loading, externalRedirectResult.value, navigate]); + + // Mark entity as checked once we've determined there's no redirect needed. + // This prevents showing loading states on subsequent sub-page navigation. + useEffect(() => { + if (!externalRedirectResult.loading && !externalRedirectResult.value) { + checkedEntityRef.current = entityKey; + } + }, [externalRedirectResult.loading, externalRedirectResult.value, entityKey]); + + // Determine if we should show a loading/progress indicator + const shouldShowProgress = + (shouldCheckForRedirect && externalRedirectResult.loading) || + !!externalRedirectResult.value; + + return { + loading: externalRedirectResult.loading, + shouldShowProgress, + }; +}