diff --git a/.changeset/eighty-numbers-act.md b/.changeset/eighty-numbers-act.md
new file mode 100644
index 0000000000..183d857d92
--- /dev/null
+++ b/.changeset/eighty-numbers-act.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': patch
+---
+
+Update to documentation regarding TechDocs redirects.
diff --git a/.changeset/hungry-carrots-grow.md b/.changeset/hungry-carrots-grow.md
new file mode 100644
index 0000000000..748ace8d94
--- /dev/null
+++ b/.changeset/hungry-carrots-grow.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs': minor
+---
+
+Adding redirect handling for TechDocs URLs that reference entities that now reference an external entity for TechDocs. Including tests and documentation.
diff --git a/.changeset/lovely-actors-love.md b/.changeset/lovely-actors-love.md
new file mode 100644
index 0000000000..f645cc5b3f
--- /dev/null
+++ b/.changeset/lovely-actors-love.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-techdocs-backend': minor
+---
+
+Adding new entity that specifies an external entity in the techdocs-entity annotation.
diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md
index cdd93b50ac..d8a2e1d232 100644
--- a/docs/features/software-catalog/well-known-annotations.md
+++ b/docs/features/software-catalog/well-known-annotations.md
@@ -126,7 +126,7 @@ metadata:
The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of
-another entity, not just linking to the root of another entities TechDocs.
+another entity, not just linking to the root of another entity's TechDocs.
### backstage.io/view-url, backstage.io/edit-url
diff --git a/docs/features/techdocs/FAQ.md b/docs/features/techdocs/FAQ.md
index 117471f749..323489ddab 100644
--- a/docs/features/techdocs/FAQ.md
+++ b/docs/features/techdocs/FAQ.md
@@ -57,3 +57,7 @@ your `mkdocs.yml` files per
If the host name of your source code hosting URL does not include `github` or
`gitlab`, an `integrations` entry in your `app-config.yaml` pointed at your
source code provider is also needed (only the `host` key is necessary).
+
+#### What happens when you navigate to a TechDocs URL for an entity uses the `backstage.io/techdocs-entity` annotation?
+
+If you navigate to a TechDocs URL in the format `docs/{namespace}/{kind}/{name}` for an entity that has the `backstage.io/techdocs-entity` annotation (instead of the `backstage.io/techdocs-ref` annotation), then Backstage will redirect to the TechDocs page of the entity referenced in the value of that annotation.
diff --git a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml
index 6b30213a60..4d3b71eb2a 100644
--- a/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml
+++ b/plugins/techdocs-backend/examples/documented-component/catalog-info.yaml
@@ -10,3 +10,17 @@ spec:
type: service
lifecycle: experimental
owner: user:guest
+---
+apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: techdocs-entity-documented-component
+ title: Example Entity Documented By TechDocs Entity Annotation
+ description: A Service with TechDocs documentation via the `backstage.io/techdocs-entity` annotation.
+ annotations:
+ backstage.io/techdocs-entity: component:default/documented-component
+ backstage.io/techdocs-entity-path: /inner-component-docs
+spec:
+ type: service
+ lifecycle: experimental
+ owner: user:guest
\ No newline at end of file
diff --git a/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md b/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md
new file mode 100644
index 0000000000..8cb6498c55
--- /dev/null
+++ b/plugins/techdocs-backend/examples/documented-component/docs/inner-component-docs/index.md
@@ -0,0 +1,5 @@
+# Inner Component Docs
+
+This is a basic example of documentation within a larger suite of TechDocs that can be referenced by whatever entities necessary. It is intended as a showcase of the `backstage.io/techdocs-entity-path` annotation for linking to a "subpage" in TechDocs that are declared by another entity.
+
+Please review the [How-To Guides - Deep Linking Into TechDocs](../../../../../../docs/features/techdocs/how-to-guides.md#deep-linking-into-techdocs) section for more information and you can view the example usage on the "Example Entity Documented By TechDocs Entity Annotation" component in this [catalog-info.yaml](../../catalog-info.yaml) file.
diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
index 1a159a4d12..ab97326913 100644
--- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
+++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml
@@ -7,6 +7,8 @@ nav:
- Subpage: sub-page.md
- 'Code Sample': code/code-sample.md
- Extensions: extensions.md
+ - 'Inner Component Docs': inner-component-docs/index.md
plugins:
- techdocs-core
+
\ No newline at end of file
diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts
index e6daf99b26..aebb0b3e52 100644
--- a/plugins/techdocs-react/src/helpers.ts
+++ b/plugins/techdocs-react/src/helpers.ts
@@ -64,7 +64,7 @@ export function getEntityRootTechDocsPath(entity: Entity): string {
/**
* Build the TechDocs URL for the given entity. This helper should be used anywhere there
- * is a link to an entities TechDocs.
+ * is a link to an entity's TechDocs.
*
* @public
*/
@@ -104,7 +104,7 @@ export const buildTechDocsURL = (
});
// Add on the external entity path to the url if one exists. This allows deep linking into another
- // entities TechDocs.
+ // entity's TechDocs.
const path = getEntityRootTechDocsPath(entity);
return `${url}${path}`;
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx
index 2af4915ef5..93f24e9f97 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.test.tsx
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import {
+ catalogApiRef,
entityPresentationApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
@@ -30,9 +31,10 @@ import {
import { techdocsApiRef, techdocsStorageApiRef } from '../../../api';
import { rootRouteRef, rootDocsRouteRef } from '../../../routes';
+import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common';
import { TechDocsReaderPage } from './TechDocsReaderPage';
-import { Route, useParams } from 'react-router-dom';
+import { Route, useNavigate, useParams } from 'react-router-dom';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -94,6 +96,10 @@ const entityPresentationApiMock: jest.Mocked<
}),
};
+const catalogApiMock = {
+ getEntityByRef: jest.fn().mockResolvedValue(mockEntityMetadata),
+};
+
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
@@ -114,6 +120,11 @@ jest.mock('@backstage/core-components', () => ({
Page: jest.fn(),
}));
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+}));
+
const configApi = mockApis.config({
data: { app: { baseUrl: 'http://localhost:3000' } },
});
@@ -129,6 +140,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => {
[techdocsApiRef, techdocsApiMock],
[techdocsStorageApiRef, techdocsStorageApiMock],
[entityPresentationApiRef, entityPresentationApiMock],
+ [catalogApiRef, catalogApiMock],
]}
>
{children}
@@ -143,6 +155,8 @@ const mountedRoutes = {
};
describe('', () => {
+ const mockNavigate = jest.fn();
+
beforeEach(() => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
@@ -150,6 +164,8 @@ describe('', () => {
// Expires in 10 minutes
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
});
+
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
});
afterEach(() => {
@@ -285,4 +301,117 @@ describe('', () => {
expect(text).toHaveStyle('fontFamily: Comic Sans MS');
});
+
+ describe('external TechDocs redirect', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ catalogApiMock.getEntityByRef.mockReset();
+ catalogApiMock.getEntityByRef.mockResolvedValue(mockEntityMetadata);
+ });
+
+ it('should navigate to external URL when entity has external techdocs annotation', async () => {
+ const mockEntityWithExternalAnnotation = {
+ ...mockEntityMetadata,
+ metadata: {
+ ...mockEntityMetadata.metadata,
+ annotations: {
+ [TECHDOCS_EXTERNAL_ANNOTATION]:
+ 'component:external-namespace/external-docs',
+ },
+ },
+ };
+
+ catalogApiMock.getEntityByRef.mockResolvedValue(
+ mockEntityWithExternalAnnotation,
+ );
+
+ await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes,
+ },
+ );
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ '/docs/external-namespace/component/external-docs',
+ { replace: true },
+ );
+ });
+
+ it('should render normally when entity has no external techdocs annotation', async () => {
+ const mockEntityWithoutExternalAnnotation = {
+ ...mockEntityMetadata,
+ metadata: {
+ ...mockEntityMetadata.metadata,
+ annotations: undefined,
+ },
+ };
+
+ catalogApiMock.getEntityByRef.mockResolvedValue(
+ mockEntityWithoutExternalAnnotation,
+ );
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes,
+ },
+ );
+
+ expect(rendered.container.querySelector('header')).toBeInTheDocument();
+ expect(rendered.container.querySelector('article')).toBeInTheDocument();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it('should render normally when entity has external annotation but no value', async () => {
+ const mockEntityWithEmptyExternalAnnotation = {
+ ...mockEntityMetadata,
+ metadata: {
+ ...mockEntityMetadata.metadata,
+ annotations: {
+ [TECHDOCS_EXTERNAL_ANNOTATION]: '',
+ },
+ },
+ };
+
+ catalogApiMock.getEntityByRef.mockResolvedValue(
+ mockEntityWithEmptyExternalAnnotation,
+ );
+
+ const rendered = await renderInTestApp(
+
+
+ ,
+ {
+ mountedRoutes,
+ },
+ );
+
+ expect(rendered.container.querySelector('header')).toBeInTheDocument();
+ expect(rendered.container.querySelector('article')).toBeInTheDocument();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx
index 80545151d9..9b4999899d 100644
--- a/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx
+++ b/plugins/techdocs/src/reader/components/TechDocsReaderPage/TechDocsReaderPage.tsx
@@ -14,19 +14,26 @@
* limitations under the License.
*/
-import { Children, ReactElement, ReactNode } from 'react';
-import { useOutlet } from 'react-router-dom';
-
+import {
+ Children,
+ ReactElement,
+ ReactNode,
+ useEffect,
+ useMemo,
+ useCallback,
+} from 'react';
+import { useOutlet, useNavigate } from 'react-router-dom';
import { Page } 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';
import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader';
@@ -34,9 +41,11 @@ 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,
@@ -44,6 +53,7 @@ import {
ThemeProvider,
useTheme,
} from '@material-ui/core/styles';
+import { Progress } from '@backstage/core-components';
/* An explanation for the multiple ways of customizing the TechDocs reader page
@@ -177,44 +187,100 @@ const StyledPage = styled(Page)({
export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
const currentTheme = useTheme();
- const readerPageTheme = createTheme({
- ...currentTheme,
- ...(props.overrideThemeOptions || {}),
- });
+ const readerPageTheme = useMemo(
+ () =>
+ createTheme({
+ ...currentTheme,
+ ...(props.overrideThemeOptions || {}),
+ }),
+ [currentTheme, props.overrideThemeOptions],
+ );
+
const { kind, name, namespace } = useRouteRefParams(rootDocsRouteRef);
const { children, entityRef = { kind, name, namespace } } = props;
const outlet = useOutlet();
- if (!children) {
+ const catalogApi = useApi(catalogApiRef);
+ const navigate = useNavigate();
+ const viewTechdocLink = useRouteRef(rootDocsRouteRef);
+
+ const memoizedEntityRef = useMemo(
+ () => ({
+ kind: entityRef.kind,
+ name: entityRef.name,
+ namespace: entityRef.namespace,
+ }),
+ [entityRef.kind, entityRef.name, entityRef.namespace],
+ );
+
+ const externalEntityTechDocsUrl = useAsync(async () => {
+ const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef);
+
+ if (catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
+ return buildTechDocsURL(catalogEntity, viewTechdocLink);
+ }
+
+ return undefined;
+ }, [memoizedEntityRef, catalogApi, viewTechdocLink]);
+
+ const handleNavigation = useCallback(
+ (url: string) => {
+ navigate(url, { replace: true });
+ },
+ [navigate],
+ );
+
+ useEffect(() => {
+ if (!externalEntityTechDocsUrl.loading && externalEntityTechDocsUrl.value) {
+ handleNavigation(externalEntityTechDocsUrl.value);
+ }
+ }, [
+ externalEntityTechDocsUrl.loading,
+ externalEntityTechDocsUrl.value,
+ handleNavigation,
+ ]);
+
+ const page: ReactNode = useMemo(() => {
+ if (children) {
+ return null;
+ }
+
const childrenList = outlet ? Children.toArray(outlet.props.children) : [];
const grandChildren = childrenList.flatMap(
child => (child as ReactElement)?.props?.children ?? [],
);
- const page: ReactNode = grandChildren.find(
+ return grandChildren.find(
grandChild =>
!getComponentData(grandChild, TECHDOCS_ADDONS_WRAPPER_KEY) &&
!getComponentData(grandChild, TECHDOCS_ADDONS_KEY),
);
+ }, [children, outlet]);
- // As explained above, "page" is configuration 4 and is 1
+ if (externalEntityTechDocsUrl.loading || externalEntityTechDocsUrl.value) {
+ return ;
+ }
+
+ // As explained above, "page" is configuration 4 and is 1
+ if (!children) {
return (
-
+
{(page as JSX.Element) || }
);
}
+
// As explained above, a render function is configuration 3 and React element is 2
return (
-
+
{({ metadata, entityMetadata, onReady }) => (
{
>
{children instanceof Function
? children({
- entityRef,
+ entityRef: memoizedEntityRef,
techdocsMetadataValue: metadata.value,
entityMetadataValue: entityMetadata.value,
onReady,