diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md new file mode 100644 index 0000000000..c5e2fe0c85 --- /dev/null +++ b/.changeset/smooth-eels-think.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-techdocs': minor +'@backstage/plugin-techdocs-react': 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`. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 974fa80993..42cefd3adb 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -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 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. + ### backstage.io/view-url, backstage.io/edit-url ```yaml diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 8a95c86533..7d8c99ffda 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -942,6 +942,35 @@ metadata: backstage.io/techdocs-entity: system:default/example ``` +### Deep linking into TechDocs + +The `backstage.io/techdocs-entity-path` annotation can be use to deep link into a specific page within the components TechDocs. +This can be used in conjunction with `backstage.io/techdocs-entity` or standalone. + +```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. diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 5ef3748e38..ac500b037d 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -72,6 +72,8 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", + "@backstage/plugin-techdocs-common": "workspace:^", + "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 0255339238..f8bebcf037 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -589,6 +589,64 @@ describe('', () => { ); }); + 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( + + + + + , + { + 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', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index bf5be95a08..88933b2a66 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -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,10 +64,11 @@ 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'; - -const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; - -const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity'; +import { buildTechDocsURL } from '@backstage/plugin-techdocs-react'; +import { + TECHDOCS_ANNOTATION, + TECHDOCS_EXTERNAL_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; const useStyles = makeStyles({ gridItemCard: { @@ -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: , - 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]; diff --git a/plugins/techdocs-common/report.api.md b/plugins/techdocs-common/report.api.md index c7e1829a28..56a86778a8 100644 --- a/plugins/techdocs-common/report.api.md +++ b/plugins/techdocs-common/report.api.md @@ -8,4 +8,8 @@ export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; // @public (undocumented) export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity'; + +// @public (undocumented) +export const TECHDOCS_EXTERNAL_PATH_ANNOTATION = + 'backstage.io/techdocs-entity-path'; ``` diff --git a/plugins/techdocs-common/src/constants.ts b/plugins/techdocs-common/src/constants.ts index 40187f41ce..a31951204e 100644 --- a/plugins/techdocs-common/src/constants.ts +++ b/plugins/techdocs-common/src/constants.ts @@ -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'; diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json index b1be611d68..e41a231f9b 100644 --- a/plugins/techdocs-react/package.json +++ b/plugins/techdocs-react/package.json @@ -63,6 +63,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/styles": "^4.11.0", diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md index 23a9c9faaf..62ac6375fa 100644 --- a/plugins/techdocs-react/report.api.md +++ b/plugins/techdocs-react/report.api.md @@ -15,8 +15,21 @@ import { JSX as JSX_2 } from 'react/jsx-runtime'; import { MemoExoticComponent } from 'react'; import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; +import { RouteFunc } from '@backstage/core-plugin-api'; import { SetStateAction } from 'react'; +// @public +export const buildTechDocsURL: ( + entity: Entity, + routeFunc: + | RouteFunc<{ + namespace: string; + kind: string; + name: string; + }> + | undefined, +) => string | undefined; + // @public export function createTechDocsAddonExtension( options: TechDocsAddonOptions, @@ -27,6 +40,9 @@ export function createTechDocsAddonExtension( options: TechDocsAddonOptions, ): Extension<(props: TComponentProps) => JSX.Element | null>; +// @public +export function getEntityRootTechDocsPath(entity: Entity): string; + // @public export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD'; diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index 3f10fff2ba..e6daf99b26 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -15,7 +15,17 @@ */ import { Config } from '@backstage/config'; -import { CompoundEntityRef } from '@backstage/catalog-model'; +import { + CompoundEntityRef, + Entity, + getCompoundEntityRef, + parseEntityRef, +} from '@backstage/catalog-model'; +import { + TECHDOCS_EXTERNAL_ANNOTATION, + TECHDOCS_EXTERNAL_PATH_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; +import { RouteFunc } from '@backstage/core-plugin-api'; /** * Lower-case entity triplets by default, but allow override. @@ -35,3 +45,67 @@ export function toLowercaseEntityRefMaybe( return entityRef; } + +/** + * Get the entity path annotation from the given entity and ensure it starts with a slash. + * + * @public + */ +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; +} + +/** + * Build the TechDocs URL for the given entity. This helper should be used anywhere there + * is a link to an entities TechDocs. + * + * @public + */ +export const buildTechDocsURL = ( + entity: Entity, + routeFunc: + | RouteFunc<{ + namespace: string; + kind: string; + name: string; + }> + | undefined, +) => { + if (!routeFunc) { + return undefined; + } + + let { namespace, kind, name } = getCompoundEntityRef(entity); + + 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}`; +}; diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts index 5cbab1a326..7c4f316c1e 100644 --- a/plugins/techdocs-react/src/index.ts +++ b/plugins/techdocs-react/src/index.ts @@ -52,4 +52,8 @@ export { useShadowRootElements, useShadowRootSelection, } from './hooks'; -export { toLowercaseEntityRefMaybe } from './helpers'; +export { + toLowercaseEntityRefMaybe, + getEntityRootTechDocsPath, + buildTechDocsURL, +} from './helpers'; diff --git a/plugins/techdocs/report.api.md b/plugins/techdocs/report.api.md index bb7ff9e5a5..2369466674 100644 --- a/plugins/techdocs/report.api.md +++ b/plugins/techdocs/report.api.md @@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: ( // @public export type TechDocsReaderPageContentProps = { entityRef?: CompoundEntityRef; + defaultPath?: string; withSearch?: boolean; searchResultUrlMapper?: (url: string) => string; onReady?: () => void; diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 68c26cd1c3..14dcb00d29 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -20,6 +20,7 @@ import { parseEntityRef, } from '@backstage/catalog-model'; import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; +import { getEntityRootTechDocsPath } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPage } from './plugin'; import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent'; @@ -52,12 +53,15 @@ export const EntityPageDocs = ({ } } + const defaultPath = getEntityRootTechDocsPath(entity); + return ( ); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx index a8e7ae7287..77bb3d134f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx @@ -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('', () => { }); }); + 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( + + + , + ); + + 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')); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 1db97a82b8..fe72b09073 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -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); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index 9dbb9745d3..10143aa99c 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + // useRef, + useState, +} from 'react'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import { useTheme } from '@material-ui/core/styles'; @@ -47,10 +53,27 @@ 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 = useRef(false); + + const location = useLocation(); + const navigate = useNavigate(); + const { '*': currPath = '' } = useParams(); + + useLayoutEffect(() => { + if (currPath === '' && defaultPath !== '') { + navigate(`${location.pathname}${defaultPath}`, { replace: true }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps +}; + /** * 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(null); const isStyleLoading = useShadowDomStylesLoading(dom); + useInitialRedirect(defaultPath); + const updateSidebarPositionAndHeight = useCallback(() => { if (!dom) return; diff --git a/yarn.lock b/yarn.lock index 5276e3d24a..f3e61fd4ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6136,6 +6136,8 @@ __metadata: "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" + "@backstage/plugin-techdocs-common": "workspace:^" + "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -8266,6 +8268,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-techdocs-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^"