feat: add techdocs-entity-path annotation for techdocs deep linking
This annotation enables specifying a path within another entities techdocs to use as the root techdocs page. Signed-off-by: Chris Suich <csuich2@gmail.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': minor
|
||||
'@backstage/plugin-catalog': minor
|
||||
'@backstage/plugin-techdocs-common': patch
|
||||
---
|
||||
|
||||
Introduced "backstage.io/techdocs-entity-path" annotation which allows deep linking into another entities TechDocs in conjunction with "backstage.io/techdocs-entity".
|
||||
@@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs.
|
||||
|
||||
This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location.
|
||||
|
||||
### backstage.io/techdocs-entity-path
|
||||
|
||||
```yaml
|
||||
# Example:
|
||||
metadata:
|
||||
annotations:
|
||||
backstage.io/techdocs-entity: component:default/example
|
||||
backstage.io/techdocs-entity-path: /path/to/this/component
|
||||
```
|
||||
|
||||
The value of this annotation informs of the path to this components 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.
|
||||
|
||||
### backstage.io/view-url, backstage.io/edit-url
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -942,6 +942,35 @@ metadata:
|
||||
backstage.io/techdocs-entity: system:default/example
|
||||
```
|
||||
|
||||
### Deep linking into another components TechDocs
|
||||
|
||||
In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a
|
||||
specific page within another component's TechDocs.
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: System
|
||||
metadata:
|
||||
name: example
|
||||
namespace: default
|
||||
title: Example
|
||||
description: This is the parent entity
|
||||
annotations:
|
||||
backstage.io/techdocs-ref: dir:.
|
||||
|
||||
---
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: example-platfrom
|
||||
title: Example Application Platform
|
||||
namespace: default
|
||||
description: This is the child entity
|
||||
annotations:
|
||||
backstage.io/techdocs-entity: system:default/example
|
||||
backstage.io/techdocs-entity-path: /path/to/component/docs
|
||||
```
|
||||
|
||||
## How to resolve broken links from moved or renamed pages in your documentation site
|
||||
|
||||
TechDocs supports using the [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects/tree/master) plugin to create a redirect map for any TechDocs site. This allows broken links from renamed or moved pages in your site to be redirected to their specified replacement.
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^",
|
||||
"@backstage/plugin-search-common": "workspace:^",
|
||||
"@backstage/plugin-search-react": "workspace:^",
|
||||
"@backstage/plugin-techdocs": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
|
||||
@@ -589,6 +589,64 @@ describe('<AboutCard />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders techdocs link to specific path', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'software',
|
||||
annotations: {
|
||||
'backstage.io/techdocs-entity': 'system:default/example',
|
||||
'backstage.io/techdocs-entity-path': '/path/to/component',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
owner: 'guest',
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
},
|
||||
};
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host: 'github.com',
|
||||
token: '...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, {}],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name': viewTechDocRouteRef,
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/default/system/example/path/to/component',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders techdocs link', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
|
||||
@@ -16,10 +16,8 @@
|
||||
import {
|
||||
ANNOTATION_EDIT_URL,
|
||||
ANNOTATION_LOCATION,
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
@@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { catalogTranslationRef } from '../../alpha/translation';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { buildTechDocsURL } from '@backstage/plugin-techdocs';
|
||||
|
||||
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
|
||||
@@ -135,19 +134,6 @@ export function AboutCard(props: AboutCardProps) {
|
||||
const entityMetadataEditUrl =
|
||||
entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
|
||||
|
||||
let techdocsRef: CompoundEntityRef | undefined;
|
||||
|
||||
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
|
||||
try {
|
||||
techdocsRef = parseEntityRef(
|
||||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
|
||||
);
|
||||
// not a fan of this but we don't care if the parseEntityRef fails
|
||||
} catch {
|
||||
techdocsRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const viewInSource: IconLinkVerticalProps = {
|
||||
label: t('aboutCard.viewSource'),
|
||||
disabled: !entitySourceLocation,
|
||||
@@ -162,19 +148,7 @@ export function AboutCard(props: AboutCardProps) {
|
||||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
|
||||
) || !viewTechdocLink,
|
||||
icon: <DocsIcon />,
|
||||
href:
|
||||
viewTechdocLink &&
|
||||
(techdocsRef
|
||||
? viewTechdocLink({
|
||||
namespace: techdocsRef.namespace || DEFAULT_NAMESPACE,
|
||||
kind: techdocsRef.kind,
|
||||
name: techdocsRef.name,
|
||||
})
|
||||
: viewTechdocLink({
|
||||
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
})),
|
||||
href: buildTechDocsURL(entity, viewTechdocLink),
|
||||
};
|
||||
|
||||
const subHeaderLinks = [viewInSource, viewInTechDocs];
|
||||
|
||||
@@ -18,3 +18,6 @@
|
||||
export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
/** @public */
|
||||
export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity';
|
||||
/** @public */
|
||||
export const TECHDOCS_EXTERNAL_PATH_ANNOTATION =
|
||||
'backstage.io/techdocs-entity-path';
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TechDocsReaderPage } from './plugin';
|
||||
import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent';
|
||||
import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader';
|
||||
import { useEntityPageTechDocsRedirect } from './search/hooks/useTechDocsLocation';
|
||||
import { getEntityRootTechDocsPath } from './helpers';
|
||||
|
||||
type EntityPageDocsProps = {
|
||||
entity: Entity;
|
||||
@@ -52,12 +53,15 @@ export const EntityPageDocs = ({
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPath = getEntityRootTechDocsPath(entity);
|
||||
|
||||
return (
|
||||
<TechDocsReaderPage entityRef={entityRef}>
|
||||
<TechDocsReaderPageSubheader />
|
||||
<TechDocsReaderPageContent
|
||||
withSearch={withSearch}
|
||||
searchResultUrlMapper={searchResultUrlMapper}
|
||||
defaultPath={defaultPath}
|
||||
/>
|
||||
</TechDocsReaderPage>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
TECHDOCS_EXTERNAL_ANNOTATION,
|
||||
TECHDOCS_EXTERNAL_PATH_ANNOTATION,
|
||||
} from '@backstage/plugin-techdocs-common';
|
||||
import { RouteFunc } from '@backstage/core-plugin-api';
|
||||
|
||||
export type TechDocsRouteFunc = RouteFunc<{
|
||||
namespace: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
// Lower-case entity triplets by default, but allow override.
|
||||
export function toLowerMaybe(str: string, config: Config) {
|
||||
@@ -24,3 +40,52 @@ export function toLowerMaybe(str: string, config: Config) {
|
||||
? str
|
||||
: str.toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
export function getEntityRootTechDocsPath(entity: Entity): string {
|
||||
let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION];
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
if (!path.startsWith('/')) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export const buildTechDocsURL = (
|
||||
entity: Entity,
|
||||
routeFunc: TechDocsRouteFunc | undefined,
|
||||
) => {
|
||||
if (!routeFunc) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE;
|
||||
let kind = entity.kind;
|
||||
let name = entity.metadata.name;
|
||||
|
||||
if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
|
||||
try {
|
||||
const techdocsRef = parseEntityRef(
|
||||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION],
|
||||
);
|
||||
namespace = techdocsRef.namespace;
|
||||
kind = techdocsRef.kind;
|
||||
name = techdocsRef.name;
|
||||
} catch {
|
||||
// not a fan of this but we don't care if the parseEntityRef fails
|
||||
}
|
||||
}
|
||||
|
||||
const url = routeFunc({
|
||||
namespace,
|
||||
kind,
|
||||
name,
|
||||
});
|
||||
|
||||
// Add on the external entity path to the url if one exists. This allows deep linking into another
|
||||
// entities TechDocs.
|
||||
const path = getEntityRootTechDocsPath(entity);
|
||||
|
||||
return `${url}${path}`;
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ export {
|
||||
LegacyEmbeddedDocsRouter as EmbeddedDocsRouter,
|
||||
Router,
|
||||
} from './Router';
|
||||
export { buildTechDocsURL, getEntityRootTechDocsPath } from './helpers';
|
||||
|
||||
export type { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem';
|
||||
|
||||
@@ -69,3 +70,5 @@ export type {
|
||||
};
|
||||
|
||||
export * from './overridableComponents';
|
||||
|
||||
export type { TechDocsRouteFunc } from './helpers';
|
||||
|
||||
+31
-1
@@ -16,7 +16,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
CompoundEntityRef,
|
||||
getCompoundEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
techdocsApiRef,
|
||||
TechDocsReaderPageProvider,
|
||||
@@ -121,6 +124,33 @@ describe('<TechDocsReaderPageContent />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should render techdocs page content with default path', async () => {
|
||||
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
|
||||
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
|
||||
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
|
||||
useReaderState.mockReturnValue({ state: 'cached' });
|
||||
|
||||
const defaultPath = '/some/path';
|
||||
|
||||
const rendered = await renderInTestApp(
|
||||
<Wrapper>
|
||||
<TechDocsReaderPageContent
|
||||
withSearch={false}
|
||||
defaultPath={defaultPath}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
rendered.getByTestId('techdocs-native-shadowroot'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const entityRef = getCompoundEntityRef(mockEntityMetadata);
|
||||
expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath);
|
||||
});
|
||||
|
||||
it('should not render techdocs content if entity metadata is missing', async () => {
|
||||
getEntityMetadata.mockResolvedValue(undefined);
|
||||
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
|
||||
|
||||
+6
-1
@@ -61,6 +61,11 @@ export type TechDocsReaderPageContentProps = {
|
||||
* @deprecated No need to pass down entityRef as property anymore. Consumes the entityName from `TechDocsReaderPageContext`. Use the {@link @backstage/plugin-techdocs-react#useTechDocsReaderPage} hook for custom reader page content.
|
||||
*/
|
||||
entityRef?: CompoundEntityRef;
|
||||
/**
|
||||
* Path in the docs to render by default. This should be used when rendering docs for an entity that specifies the
|
||||
* "backstage.io/techdocs-entity-path" annotation for deep linking into another entities docs.
|
||||
*/
|
||||
defaultPath?: string;
|
||||
/**
|
||||
* Show or hide the search bar, defaults to true.
|
||||
*/
|
||||
@@ -93,7 +98,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
|
||||
setShadowRoot,
|
||||
} = useTechDocsReaderPage();
|
||||
const { state } = useTechDocsReader();
|
||||
const dom = useTechDocsReaderDom(entityRef);
|
||||
const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
|
||||
const path = window.location.pathname;
|
||||
const hash = window.location.hash;
|
||||
const isStyleLoading = useShadowDomStylesLoading(dom);
|
||||
|
||||
@@ -47,10 +47,33 @@ import {
|
||||
handleMetaRedirects,
|
||||
} from '../../transformers';
|
||||
import { useNavigateUrl } from './useNavigateUrl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
|
||||
|
||||
// If a defaultPath is specified then we should navigate to that path replacing the
|
||||
// current location in the history. This should only happen on the initial load so
|
||||
// navigating to the root of the docs doesn't also redirect.
|
||||
const useInitialRedirect = (defaultPath?: string) => {
|
||||
const [hasRun, setHasRun] = useState(false);
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { '*': currPath = '' } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once
|
||||
if (hasRun) {
|
||||
return;
|
||||
}
|
||||
setHasRun(true);
|
||||
|
||||
if (currPath === '' && defaultPath !== '') {
|
||||
navigate(`${location.pathname}${defaultPath}`, { replace: true });
|
||||
}
|
||||
}, [hasRun, currPath, defaultPath, location, navigate]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that encapsulates the behavior of getting raw HTML and applying
|
||||
* transforms to it in order to make it function at a basic level in the
|
||||
@@ -58,6 +81,7 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)';
|
||||
*/
|
||||
export const useTechDocsReaderDom = (
|
||||
entityRef: CompoundEntityRef,
|
||||
defaultPath?: string,
|
||||
): Element | null => {
|
||||
const navigate = useNavigateUrl();
|
||||
const theme = useTheme();
|
||||
@@ -76,6 +100,8 @@ export const useTechDocsReaderDom = (
|
||||
const [dom, setDom] = useState<HTMLElement | null>(null);
|
||||
const isStyleLoading = useShadowDomStylesLoading(dom);
|
||||
|
||||
useInitialRedirect(defaultPath);
|
||||
|
||||
const updateSidebarPositionAndHeight = useCallback(() => {
|
||||
if (!dom) return;
|
||||
|
||||
|
||||
@@ -6355,6 +6355,7 @@ __metadata:
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^"
|
||||
"@backstage/plugin-search-common": "workspace:^"
|
||||
"@backstage/plugin-search-react": "workspace:^"
|
||||
"@backstage/plugin-techdocs": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user