chore: split to dedicated hook
Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>
This commit is contained in:
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
+282
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user