Expose an alternative, add-on-aware read component and hooks.

Co-authored-by: Emma Indal <emma.indahl@gmail.com>
Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Otto Sichert <git@ottosichert.de>
Co-authored-by: Anders Näsman <andersn@spotify.com>

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-03-16 10:57:22 +01:00
committed by Emma Indal
parent 30d2ca2669
commit ff1cc8bced
9 changed files with 717 additions and 6 deletions
+29
View File
@@ -3,9 +3,14 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { ComponentType } from 'react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Extension } from '@backstage/core-plugin-api';
import { default as React_2 } from 'react';
import { TechDocsEntityMetadata } from '@backstage/plugin-techdocs';
import { TechDocsMetadata } from '@backstage/plugin-techdocs';
// @public
export function createTechDocsAddon<TComponentProps>(
@@ -31,4 +36,28 @@ export type TechDocsAddonOptions<TAddonProps = {}> = {
// @public
export const TechDocsAddons: React_2.ComponentType;
// @public
export const TechDocsReaderPage: (
props: TechDocsReaderPageProps,
) => JSX.Element;
// @public (undocumented)
export type TechDocsReaderPageProps = {
entityName: CompoundEntityRef;
};
// @public
export const useEntityMetadata: () => TechDocsEntityMetadata | undefined;
// @public
export const useMetadata: () => TechDocsMetadata | undefined;
// @public
export const useShadowRoot: () => ShadowRoot | undefined;
// @public
export const useShadowRootElements: <T extends HTMLElement = HTMLElement>(
selectors: string[],
) => T[];
```
+10 -1
View File
@@ -22,8 +22,17 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/catalog-model": "^0.13.0",
"@backstage/core-components": "^0.9.1",
"@backstage/core-plugin-api": "^0.8.0",
"react-router-dom": "6.0.0-beta.0"
"@backstage/plugin-techdocs": "^0.15.1",
"@material-ui/core": "^4.12.2",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/styles": "^4.11.0",
"jss": "~10.8.2",
"react-helmet": "6.1.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
+193
View File
@@ -0,0 +1,193 @@
/*
* Copyright 2022 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 { CompoundEntityRef } from '@backstage/catalog-model';
import { useApi, useApp } from '@backstage/core-plugin-api';
import {
techdocsApiRef,
TechDocsEntityMetadata,
TechDocsMetadata,
} from '@backstage/plugin-techdocs';
import React, {
createContext,
Dispatch,
PropsWithChildren,
SetStateAction,
useContext,
useState,
} from 'react';
import useAsync from 'react-use/lib/useAsync';
type PropsWithEntityName = PropsWithChildren<{ entityName: CompoundEntityRef }>;
const TechDocsMetadataContext = createContext<TechDocsMetadata | undefined>(
undefined,
);
export const TechDocsMetadataProvider = ({
entityName,
children,
}: PropsWithEntityName) => {
const { NotFoundErrorPage } = useApp().getComponents();
const techdocsApi = useApi(techdocsApiRef);
const { value, loading, error } = useAsync(async () => {
return await techdocsApi.getTechDocsMetadata(entityName);
}, []);
if (!loading && error) {
return <NotFoundErrorPage />;
}
return (
<TechDocsMetadataContext.Provider value={value}>
{children}
</TechDocsMetadataContext.Provider>
);
};
/**
* Hook for use within TechDocs addons to retrieve TechDocs Metadata for the
* current TechDocs site.
* @public
*/
export const useMetadata = () => {
return useContext(TechDocsMetadataContext);
};
const TechDocsEntityContext = createContext<TechDocsEntityMetadata | undefined>(
undefined,
);
export const TechDocsEntityProvider = ({
entityName,
children,
}: PropsWithEntityName) => {
const { NotFoundErrorPage } = useApp().getComponents();
const techdocsApi = useApi(techdocsApiRef);
const { value, loading, error } = useAsync(async () => {
return await techdocsApi.getEntityMetadata(entityName);
}, []);
if (!loading && error) {
return <NotFoundErrorPage />;
}
return (
<TechDocsEntityContext.Provider value={value}>
{children}
</TechDocsEntityContext.Provider>
);
};
/**
* Hook for use within TechDocs addons to retrieve Entity Metadata for the
* current TechDocs site.
* @public
*/
export const useEntityMetadata = () => {
return useContext(TechDocsEntityContext);
};
export type TechDocsReaderPageValue = {
entityName: CompoundEntityRef;
shadowRoot?: ShadowRoot;
setShadowRoot: Dispatch<SetStateAction<ShadowRoot | undefined>>;
title: string;
setTitle: Dispatch<SetStateAction<string>>;
subtitle: string;
setSubtitle: Dispatch<SetStateAction<string>>;
};
export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = {
title: '',
setTitle: () => {},
subtitle: '',
setSubtitle: () => {},
setShadowRoot: () => {},
entityName: { kind: '', name: '', namespace: '' },
};
export const TechDocsReaderPageContext = createContext<TechDocsReaderPageValue>(
defaultTechDocsReaderPageValue,
);
export const useTechDocsReaderPage = () => {
return useContext(TechDocsReaderPageContext);
};
export const TechDocsReaderPageProvider = ({
entityName,
children,
}: PropsWithEntityName) => {
const [title, setTitle] = useState(defaultTechDocsReaderPageValue.title);
const [subtitle, setSubtitle] = useState(
defaultTechDocsReaderPageValue.subtitle,
);
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | undefined>(
defaultTechDocsReaderPageValue.shadowRoot,
);
const value = {
entityName,
shadowRoot,
setShadowRoot,
title,
setTitle,
subtitle,
setSubtitle,
};
return (
<TechDocsReaderPageContext.Provider value={value}>
{children}
</TechDocsReaderPageContext.Provider>
);
};
/**
* Hook for use within TechDocs addons that provides access to the underlying
* shadow root of the current page, allowing the DOM within to be mutated.
* @public
*/
export const useShadowRoot = () => {
const { shadowRoot } = useTechDocsReaderPage();
return shadowRoot;
};
/**
* Convenience hook for use within TechDocs addons that provides access to
* elements that match a given selector within the shadow root.
*
* todo(backstage/techdocs-core): Consider extending `selectors` from string[]
* to some kind of typed object array, so users have more control over the
* shape of the result. e.g. a flag to indicate querySelector vs.
* querySelectorAll.
*
* @public
*/
export const useShadowRootElements = <T extends HTMLElement = HTMLElement>(
selectors: string[],
): T[] => {
const shadowRoot = useShadowRoot();
if (!shadowRoot) return [];
return selectors
.map(selector => shadowRoot?.querySelectorAll<T>(selector))
.filter(nodeList => nodeList.length)
.map(nodeList => Array.from(nodeList))
.flat();
};
+8
View File
@@ -21,4 +21,12 @@
*/
export { createTechDocsAddon, TechDocsAddons } from './addons';
export {
useEntityMetadata,
useMetadata,
useShadowRoot,
useShadowRootElements,
} from './context';
export { TechDocsReaderPage } from './reader';
export type { TechDocsReaderPageProps } from './reader';
export type { TechDocsAddonLocations, TechDocsAddonOptions } from './types';
+217
View File
@@ -0,0 +1,217 @@
/*
* Copyright 2022 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 { CompoundEntityRef } from '@backstage/catalog-model';
import { Content, Header, Page, Progress } from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
// todo(backstage/techdocs-core): Export these from @backstage/plugin-techdocs
import {
// @ts-ignore
useTechDocsReaderDom,
// @ts-ignore
withTechDocsReaderProvider,
// @ts-ignore
TechDocsStateIndicator as TechDocReaderPageIndicator,
} from '@backstage/plugin-techdocs';
import {
withStyles,
Portal,
Box,
Toolbar,
ToolbarProps,
} from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import { StylesProvider, jssPreset } from '@material-ui/styles';
import React, { useEffect, useRef, useState } from 'react';
import { create } from 'jss';
import Helmet from 'react-helmet';
import { useTechDocsAddons } from './addons';
import {
TechDocsMetadataProvider,
useMetadata,
TechDocsEntityProvider,
TechDocsReaderPageProvider,
useTechDocsReaderPage,
} from './context';
import { TechDocsAddonLocations as locations } from './types';
const TechDocsReaderPageSubheader = withStyles(theme => ({
root: {
gridArea: 'pageSubheader',
flexDirection: 'column',
minHeight: 'auto',
padding: theme.spacing(3, 3, 0),
},
}))(({ ...rest }: ToolbarProps) => {
const addons = useTechDocsAddons();
if (!addons.renderComponentsWithLocation(locations.SUBHEADER)) return null;
return (
<Toolbar {...rest}>
{addons.renderComponentsWithLocation(locations.SUBHEADER) && (
<Box
display="flex"
justifyContent="flex-end"
width="100%"
flexWrap="wrap"
>
{addons.renderComponentsWithLocation(locations.SUBHEADER)}
</Box>
)}
</Toolbar>
);
});
const skeleton = <Skeleton animation="wave" variant="text" height={40} />;
const TechDocsReaderPageHeader = () => {
const addons = useTechDocsAddons();
const configApi = useApi(configApiRef);
const metadata = useMetadata();
const { title, setTitle, subtitle, setSubtitle } = useTechDocsReaderPage();
useEffect(() => {
if (!metadata) return;
setTitle(prevTitle => prevTitle || metadata.site_name);
setSubtitle(
prevSubtitle => prevSubtitle || metadata.site_description || 'Home',
);
}, [metadata, setTitle, setSubtitle]);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const tabTitle = [subtitle, title, appTitle].filter(Boolean).join(' | ');
return (
<Header
type="Documentation"
title={title || skeleton}
subtitle={subtitle || skeleton}
>
<Helmet titleTemplate="%s">
<title>{tabTitle}</title>
</Helmet>
{addons.renderComponentsWithLocation(locations.HEADER)}
</Header>
);
};
const TechDocsReaderPageContent = () => {
const ref = useRef<HTMLDivElement>(null);
const [jss, setJss] = useState(
create({
...jssPreset(),
insertionPoint: undefined,
}),
);
const addons = useTechDocsAddons();
const { entityName, setShadowRoot } = useTechDocsReaderPage();
const dom = useTechDocsReaderDom(entityName);
useEffect(() => {
const shadowHost = ref.current;
if (!dom || !shadowHost || shadowHost.shadowRoot) return;
setJss(
create({
...jssPreset(),
insertionPoint: dom.querySelector('head') || undefined,
}),
);
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '';
shadowRoot.appendChild(dom);
setShadowRoot(shadowRoot);
}, [dom, setShadowRoot]);
const contentElement = ref.current?.shadowRoot?.querySelector(
'[data-md-component="container"]',
);
const primarySidebarElement = ref.current?.shadowRoot?.querySelector(
'[data-md-component="navigation"]',
);
const secondarySidebarElement = ref.current?.shadowRoot?.querySelector(
'[data-md-component="toc"]',
);
const primarySidebarAddonSpace = document.createElement('div');
primarySidebarElement?.prepend(primarySidebarAddonSpace);
const secondarySidebarAddonSpace = document.createElement('div');
secondarySidebarElement?.prepend(secondarySidebarAddonSpace);
// do not return content until dom is ready
if (!dom) {
return (
<Content>
<Progress />
</Content>
);
}
return (
<Content>
{/* sheetsManager={new Map()} is needed in order to deduplicate the injection of CSS in the page. */}
<StylesProvider jss={jss} sheetsManager={new Map()}>
<div ref={ref} data-testid="techdocs-native-shadowroot" />
<Portal container={primarySidebarAddonSpace}>
{addons.renderComponentsWithLocation(locations.PRIMARY_SIDEBAR)}
</Portal>
<Portal container={contentElement}>
{addons.renderComponentsWithLocation(locations.CONTENT)}
</Portal>
<Portal container={secondarySidebarAddonSpace}>
{addons.renderComponentsWithLocation(locations.SECONDARY_SIDEBAR)}
</Portal>
</StylesProvider>
</Content>
);
};
/**
* @public
*/
export type TechDocsReaderPageProps = { entityName: CompoundEntityRef };
/**
* An addon-aware implementation of the TechDocsReaderPage.
* @public
*/
export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
const { entityName } = props;
const Component = withTechDocsReaderProvider(() => {
return (
<TechDocsMetadataProvider entityName={entityName}>
<TechDocsEntityProvider entityName={entityName}>
<TechDocsReaderPageProvider entityName={entityName}>
<Page themeId="documentation">
<TechDocsReaderPageHeader />
<TechDocsReaderPageSubheader />
<TechDocReaderPageIndicator />
<TechDocsReaderPageContent />
</Page>
</TechDocsReaderPageProvider>
</TechDocsEntityProvider>
</TechDocsMetadataProvider>
);
}, entityName);
return <Component />;
};
+3
View File
@@ -54,6 +54,9 @@ export enum TechDocsAddonLocations {
* every HTML node with the same tag name as the addon name in the markdown
* content. If no reference is made, no instance will be rendered. Works like
* regular React components, just being accessible from markdown.
*
* todo(backstage/techdocs-core): Keep and implement or remove before
* releasing this package!
*/
COMPONENT = 'component',
}
+1
View File
@@ -72,6 +72,7 @@
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.0.0",
"@types/event-source-polyfill": "^1.0.0",
"@types/dompurify": "^2.2.2",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",