Merge pull request #7378 from backstage/emmaindal/custom-techdocs-header

[TechDocs] Support custom TechDocs Reader Page
This commit is contained in:
Patrik Oldsberg
2021-09-30 13:37:09 +02:00
committed by GitHub
15 changed files with 384 additions and 44 deletions
+14
View File
@@ -0,0 +1,14 @@
---
'@backstage/plugin-techdocs': minor
---
Adds support for being able to customize and compose your TechDocs reader page in the App.
You can likely upgrade to this version without issue. If, however, you have
imported the `<Reader />` component in your custom code, the name of a property
has changed. You will need to make the following change anywhere you use it:
```diff
-<Reader entityId={value} />
+<Reader entityRef={value} />
```
+78
View File
@@ -142,6 +142,84 @@ const AppRoutes = () => {
};
```
## How to customize the TechDocs reader page?
Similar to how it is possible to customize the TechDocs Home, it is also
possible to customize the TechDocs Reader Page. It is done in your `app`
package. By default, you might see something like this in your `App.tsx`:
```tsx
const AppRoutes = () => {
<Route path="/docs/:namespace/:kind/:name/*" element={<TechDocsReaderPage />}>
{techDocsPage}
</Route>;
};
```
The `techDocsPage` is a default techdocs reader page which lives in
`packages/app/src/components/techdocs`. It includes the following without you
having to set anything up.
```tsx
<TechDocsPage>
{({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => (
<>
<TechDocsPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityRef={entityRef}
/>
<Content data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} />
</Content>
</>
)}
</TechDocsPage>
```
If you would like to compose your own `techDocsPage`, you can do so by replacing
the children of TechDocsPage with something else. Maybe you are _just_
interested in replacing the Header:
```tsx
<TechDocsPage>
{({ entityRef, onReady }) => (
<>
<Header type="documentation" title="Custom Header" />
<Content data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} />
</Content>
</>
)}
</TechDocsPage>
```
Or maybe you want to disable the in-context search
```tsx
<TechDocsPage>
{({ entityRef, onReady }) => (
<>
<Header type="documentation" title="Custom Header" />
<Content data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} withSearch={false} />
</Content>
</>
)}
</TechDocsPage>
```
Or maybe you want to replace the entire TechDocs Page.
```tsx
<TechDocsPage>
<Header type="documentation" title="Custom Header" />
<Content data-testid="techdocs-content">
<p>my own content</p>
</Content>
</TechDocsPage>
```
## How to migrate from TechDocs Alpha to Beta
> This guide only applies to the "recommended" TechDocs deployment method (where
+5 -1
View File
@@ -84,6 +84,8 @@ import { searchPage } from './components/search/SearchPage';
import { providers } from './identityProviders';
import * as plugins from './plugins';
import { techDocsPage } from './components/techdocs/TechDocsPage';
const app = createApp({
apis,
plugins: Object.values(plugins),
@@ -170,7 +172,9 @@ const routes = (
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
/>
>
{techDocsPage}
</Route>
<Route path="/create" element={<ScaffolderPage />}>
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
@@ -0,0 +1,44 @@
/*
* Copyright 2021 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 { Content } from '@backstage/core-components';
import {
TechDocsPageHeader,
TechDocsPage,
Reader,
} from '@backstage/plugin-techdocs';
import React from 'react';
const DefaultTechDocsPage = () => {
return (
<TechDocsPage>
{({ techdocsMetadataValue, entityMetadataValue, entityRef, onReady }) => (
<>
<TechDocsPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityRef={entityRef}
/>
<Content data-testid="techdocs-content">
<Reader onReady={onReady} entityRef={entityRef} />
</Content>
</>
)}
</TechDocsPage>
);
};
export const techDocsPage = <DefaultTechDocsPage />;
+48 -2
View File
@@ -182,7 +182,7 @@ export type PanelType = 'DocsCardGrid' | 'DocsTable';
//
// @public (undocumented)
export const Reader: ({
entityId,
entityRef,
onReady,
withSearch,
}: Props_3) => JSX.Element;
@@ -264,11 +264,55 @@ export const TechDocsCustomHome: ({
// @public (undocumented)
export const TechDocsIndexPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPage: ({ children }: TechDocsPageProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechdocsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechdocsPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsPageHeader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPageHeader: ({
entityRef,
entityMetadata,
techDocsMetadata,
}: TechDocsPageHeaderProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsPageHeaderProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type TechDocsPageHeaderProps = {
entityRef: EntityName;
entityMetadata?: TechDocsEntityMetadata;
techDocsMetadata?: TechDocsMetadata;
};
// Warning: (ae-missing-release-tag) "TechDocsPageProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type TechDocsPageProps = {
children?: TechDocsPageRenderFunction | React_2.ReactNode;
};
// Warning: (ae-missing-release-tag) "TechDocsPageRenderFunction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type TechDocsPageRenderFunction = ({
techdocsMetadataValue,
entityMetadataValue,
entityRef,
}: {
techdocsMetadataValue?: TechDocsMetadata | undefined;
entityMetadataValue?: TechDocsEntityMetadata | undefined;
entityRef: EntityName;
onReady: () => void;
}) => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "TechDocsPageWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -301,7 +345,9 @@ export { techdocsPlugin };
// Warning: (ae-missing-release-tag) "TechDocsReaderPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsReaderPage: () => JSX.Element;
export const TechDocsReaderPage: ({
children,
}: TechDocsPageProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsStorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1 -1
View File
@@ -110,7 +110,7 @@ function createPage({
render() {
return (
<Reader
entityId={{
entityRef={{
kind: 'Component',
namespace: 'default',
name: 'my-docs',
+1 -1
View File
@@ -22,7 +22,7 @@ export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
return (
<Reader
withSearch={false}
entityId={{
entityRef={{
kind: entity.kind,
namespace: entity.metadata.namespace ?? 'default',
name: entity.metadata.name,
@@ -0,0 +1,77 @@
/*
* Copyright 2020 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 React, { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { techdocsApiRef } from '../../api';
import { TechDocsNotFound } from './TechDocsNotFound';
import { useApi } from '@backstage/core-plugin-api';
import { Page, Content } from '@backstage/core-components';
import { Reader } from './Reader';
import { TechDocsPageHeader } from './TechDocsPageHeader';
export const LegacyTechDocsPage = () => {
const [documentReady, setDocumentReady] = useState<boolean>(false);
const { namespace, kind, name } = useParams();
const techdocsApi = useApi(techdocsApiRef);
const { value: techdocsMetadataValue } = useAsync(() => {
if (documentReady) {
return techdocsApi.getTechDocsMetadata({ kind, namespace, name });
}
return Promise.resolve(undefined);
}, [kind, namespace, name, techdocsApi, documentReady]);
const { value: entityMetadataValue, error: entityMetadataError } =
useAsync(() => {
return techdocsApi.getEntityMetadata({ kind, namespace, name });
}, [kind, namespace, name, techdocsApi]);
const onReady = useCallback(() => {
setDocumentReady(true);
}, [setDocumentReady]);
if (entityMetadataError) {
return <TechDocsNotFound errorMessage={entityMetadataError.message} />;
}
return (
<Page themeId="documentation">
<TechDocsPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityRef={{
kind,
namespace,
name,
}}
/>
<Content data-testid="techdocs-content">
<Reader
onReady={onReady}
entityRef={{
kind,
namespace,
name,
}}
/>
</Content>
</Page>
);
};
@@ -41,7 +41,7 @@ const { useParams }: { useParams: jest.Mock } =
describe('<Reader />', () => {
it('should render Reader content', async () => {
useParams.mockReturnValue({
entityId: 'Component::backstage',
entityRef: 'Component::backstage',
});
const scmIntegrationsApi: ScmIntegrationsApi =
@@ -68,7 +68,7 @@ describe('<Reader />', () => {
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<Reader
entityId={{
entityRef={{
kind: 'Component',
namespace: 'default',
name: 'example',
@@ -48,7 +48,7 @@ import { TechDocsSearch } from './TechDocsSearch';
import { useReaderState } from './useReaderState';
type Props = {
entityId: EntityName;
entityRef: EntityName;
onReady?: () => void;
withSearch?: boolean;
};
@@ -71,8 +71,8 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export const Reader = ({ entityId, onReady, withSearch = true }: Props) => {
const { kind, namespace, name } = entityId;
export const Reader = ({ entityRef, onReady, withSearch = true }: Props) => {
const { kind, namespace, name } = entityRef;
const theme = useTheme<BackstageTheme>();
const classes = useStyles();
@@ -448,7 +448,7 @@ export const Reader = ({ entityId, onReady, withSearch = true }: Props) => {
{withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
<Grid container className={classes.searchBar}>
<TechDocsSearch entityId={entityId} />
<TechDocsSearch entityId={entityRef} />
</Grid>
)}
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
@@ -22,6 +22,7 @@ import {
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import { wrapInTestApp } from '@backstage/test-utils';
import { Header } from '@backstage/core-components';
import {
techdocsApiRef,
TechDocsApi,
@@ -53,7 +54,7 @@ global.scroll = jest.fn();
describe('<TechDocsPage />', () => {
it('should render techdocs page', async () => {
useParams.mockReturnValue({
entityId: 'Component::backstage',
entityRef: 'Component::backstage',
});
const scmIntegrationsApi: ScmIntegrationsApi =
@@ -107,4 +108,69 @@ describe('<TechDocsPage />', () => {
expect(rendered.getByTestId('techdocs-content')).toBeInTheDocument();
});
});
it('should render techdocs page with custom header', async () => {
useParams.mockReturnValue({
entityRef: 'Component::backstage',
});
const scmIntegrationsApi: ScmIntegrationsApi =
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {},
}),
);
const techdocsApi: Partial<TechDocsApi> = {
getEntityMetadata: () =>
Promise.resolve({
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'backstage',
},
}),
getTechDocsMetadata: () =>
Promise.resolve({
site_name: 'string',
site_description: 'string',
}),
};
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
getApiOrigin: (): Promise<string> => Promise.resolve('String'),
};
const searchApi = {
query: () =>
Promise.resolve({
results: [],
}),
};
const apiRegistry = ApiRegistry.from([
[scmIntegrationsApiRef, scmIntegrationsApi],
[techdocsApiRef, techdocsApi],
[techdocsStorageApiRef, techdocsStorageApi],
[searchApiRef, searchApi],
]);
await act(async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsPage>
{({ techdocsMetadataValue }) => (
<Header
type="documentation"
title="A custom header"
subtitle={techdocsMetadataValue?.site_name}
/>
)}
</TechDocsPage>
</ApiProvider>,
),
);
expect(rendered.getByText('A custom header')).toBeInTheDocument();
});
});
});
@@ -15,17 +15,35 @@
*/
import React, { useCallback, useState } from 'react';
import { useOutlet } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { techdocsApiRef } from '../../api';
import { Reader } from './Reader';
import { TechDocsNotFound } from './TechDocsNotFound';
import { TechDocsPageHeader } from './TechDocsPageHeader';
import { Content, Page } from '@backstage/core-components';
import { LegacyTechDocsPage } from './LegacyTechDocsPage';
import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types';
import { EntityName } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { Page } from '@backstage/core-components';
export type TechDocsPageRenderFunction = ({
techdocsMetadataValue,
entityMetadataValue,
entityRef,
}: {
techdocsMetadataValue?: TechDocsMetadata | undefined;
entityMetadataValue?: TechDocsEntityMetadata | undefined;
entityRef: EntityName;
onReady: () => void;
}) => JSX.Element;
export type TechDocsPageProps = {
children?: TechDocsPageRenderFunction | React.ReactNode;
};
export const TechDocsPage = ({ children }: TechDocsPageProps) => {
const outlet = useOutlet();
export const TechDocsPage = () => {
const [documentReady, setDocumentReady] = useState<boolean>(false);
const { namespace, kind, name } = useParams();
@@ -52,27 +70,18 @@ export const TechDocsPage = () => {
return <TechDocsNotFound errorMessage={entityMetadataError.message} />;
}
if (!children) return outlet || <LegacyTechDocsPage />;
return (
<Page themeId="documentation">
<TechDocsPageHeader
techDocsMetadata={techdocsMetadataValue}
entityMetadata={entityMetadataValue}
entityId={{
kind,
namespace,
name,
}}
/>
<Content data-testid="techdocs-content">
<Reader
onReady={onReady}
entityId={{
kind,
namespace,
name,
}}
/>
</Content>
{children instanceof Function
? children({
techdocsMetadataValue,
entityMetadataValue,
entityRef: { kind, namespace, name },
onReady,
})
: children}
</Page>
);
};
@@ -25,7 +25,7 @@ describe('<TechDocsPageHeader />', () => {
await act(async () => {
const rendered = await renderInTestApp(
<TechDocsPageHeader
entityId={{
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
@@ -67,7 +67,7 @@ describe('<TechDocsPageHeader />', () => {
await act(async () => {
const rendered = await renderInTestApp(
<TechDocsPageHeader
entityId={{
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
@@ -89,7 +89,7 @@ describe('<TechDocsPageHeader />', () => {
await act(async () => {
const rendered = await renderInTestApp(
<TechDocsPageHeader
entityId={{
entityRef={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
@@ -27,18 +27,18 @@ import React from 'react';
import { rootRouteRef } from '../../routes';
import { TechDocsEntityMetadata, TechDocsMetadata } from '../../types';
type TechDocsPageHeaderProps = {
entityId: EntityName;
export type TechDocsPageHeaderProps = {
entityRef: EntityName;
entityMetadata?: TechDocsEntityMetadata;
techDocsMetadata?: TechDocsMetadata;
};
export const TechDocsPageHeader = ({
entityId,
entityRef,
entityMetadata,
techDocsMetadata,
}: TechDocsPageHeaderProps) => {
const { name } = entityId;
const { name } = entityRef;
const { site_name: siteName, site_description: siteDescription } =
techDocsMetadata || {};
@@ -59,7 +59,7 @@ export const TechDocsPageHeader = ({
value={
<EntityRefLink
color="inherit"
entityRef={entityId}
entityRef={entityRef}
defaultKind="Component"
/>
}
@@ -15,3 +15,5 @@
*/
export * from './Reader';
export * from './TechDocsPage';
export * from './TechDocsPageHeader';