chore: split to dedicated hook

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>
This commit is contained in:
Gabriel Dugny
2025-10-16 18:15:19 +02:00
parent da6b06f4ff
commit 1b3d2a1bbe
3 changed files with 389 additions and 76 deletions
@@ -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<string | null>(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 <Progress />;
}
@@ -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 }) => (
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
{children}
</TestApiProvider>
);
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);
});
});
});
@@ -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<string | null>(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,
};
}