Add TechDocs landing page customization and exported components

Signed-off-by: Chongyang Adrian, Ke <ftt.adrian.ke@grabtaxi.com>
This commit is contained in:
Chongyang Adrian, Ke
2021-04-06 17:18:38 +08:00
parent 2f423757d2
commit 39bdaa0046
13 changed files with 538 additions and 244 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Add customization and exportable components for TechDocs landing page
+77
View File
@@ -82,3 +82,80 @@ Caveat: Currently TechDocs sites built using URL Reader will be cached for 30
minutes which means they will not be re-built if new changes are made within 30
minutes. This cache invalidation will be replaced by commit timestamp based
implementation very soon.
## How to use a custom TechDocs home page?
### 1st way: TechDocsCustomHome with a custom configuration
In your main App.tsx:
```
import {
TechDocsCustomHome,
WidgetType,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { Entity } from '@backstage/catalog-model';
const tabsConfig = [
{
label: 'Custom Tab',
widgets: [
{
title: 'Custom Documents Cards 1',
description:
'Explore your internal technical ecosystem through documentation.',
// sets maximum height of widget, as CSS maxHeight attribute
widgetMaxHeight: '400px'
widgetType: 'DocsCardGrid' as WidgetType,
filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationOne'];
},
{
title: 'Custom Documents Cards 2',
description:
'Explore your internal technical ecosystem through documentation.',
widgetMaxHeight: '400px'
widgetType: 'DocsCardGrid' as WidgetType,
filterPredicate: (entity: Entity) => !!entity.metadata.annotations?.['customCardAnnotationTwo'];
},
],
},
{
label: 'Overview',
widgets: [
{
title: 'Overview',
description:
'Explore your internal technical ecosystem through documentation.',
widgetType: 'DocsTable' as WidgetType,
filterPredicate: () => true,
},
],
},
];
const routes = (
<FlatRoutes>
<Route
path="/docs"
element={<TechDocsCustomHome tabsConfig={tabsConfig} />}
/>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
/>
</FlatRoutes>
```
An example of tabsConfig that corresponds to the default documentation home page
can be found at `plugins/techdocs/src/home/components/TechDocsHome.tsx`.
### 2nd way: custom home page plugin
A custom home page plugin can be built that uses the components extensions
DocsCardGrid and DocsTable, exported from @backstage/techdocs. They both take a
array of documentation entities ( have 'backstage.io/techdocs-ref' annotation )
as an 'entities' attribute.
For an reference to the React structure of the default home page, please refer
to `plugins/techdocs/src/home/components/TechDocsCustomHome.tsx`.
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,13 +17,13 @@
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { OverviewContent } from './OverviewContent';
import { DocsCardGrid } from './DocsCardGrid';
describe('TechDocs Overview Content', () => {
it('should render all TechDocs Documents', async () => {
describe('Entity Docs Card Grid', () => {
it('should render all entities passed ot it', async () => {
const { findByText } = render(
wrapInTestApp(
<OverviewContent
<DocsCardGrid
entities={[
{
apiVersion: 'version',
@@ -49,13 +49,6 @@ describe('TechDocs Overview Content', () => {
/>,
),
);
expect(await findByText('Overview')).toBeInTheDocument();
expect(
await findByText(
/Explore your internal technical ecosystem through documentation./i,
),
).toBeInTheDocument();
expect(await findByText('testName')).toBeInTheDocument();
expect(await findByText('testName2')).toBeInTheDocument();
});
@@ -0,0 +1,58 @@
/*
* Copyright 2021 Spotify AB
*
* 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 from 'react';
import { generatePath } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import { Button, ItemCardGrid, ItemCardHeader } from '@backstage/core';
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
import { rootDocsRouteRef } from '../../plugin';
export const DocsCardGrid = ({
entities,
}: {
entities: Entity[] | undefined;
}) => {
if (!entities) return null;
return (
<ItemCardGrid data-testid="docs-explore">
{!entities?.length
? null
: entities.map((entity, index: number) => (
<Card key={index}>
<CardMedia>
<ItemCardHeader title={entity.metadata.name} />
</CardMedia>
<CardContent>{entity.metadata.description}</CardContent>
<CardActions>
<Button
to={generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
})}
color="primary"
>
Read Docs
</Button>
</CardActions>
</Card>
))}
</ItemCardGrid>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,37 +16,13 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { OwnedContent } from './OwnedContent';
import { DocsTable } from './DocsTable';
jest.mock('../hooks', () => ({
useOwnUser: () => {
return {
value: {
apiVersion: 'version',
kind: 'User',
metadata: {
name: 'owned',
namespace: 'default',
},
relations: [
{
target: {
kind: 'TestKind',
name: 'testName',
},
type: 'ownerOf',
},
],
},
};
},
}));
describe('TechDocs Owned Content', () => {
it('should render TechDocs Owned Documents', async () => {
const { findByText, queryByText } = render(
describe('DocsTable test', () => {
it('should render documents passed', async () => {
const { findByText } = render(
wrapInTestApp(
<OwnedContent
<DocsTable
entities={[
{
apiVersion: 'version',
@@ -93,16 +69,12 @@ describe('TechDocs Owned Content', () => {
),
);
expect(await findByText('Owned documents')).toBeInTheDocument();
expect(await findByText(/Access your documentation./i)).toBeInTheDocument();
expect(await findByText('testName')).toBeInTheDocument();
expect(await queryByText('testName2')).not.toBeInTheDocument();
expect(await findByText('testName2')).toBeInTheDocument();
});
it('should render empty state if no owned documents exist', async () => {
const { findByText } = render(
wrapInTestApp(<OwnedContent entities={[]} />),
);
const { findByText } = render(wrapInTestApp(<DocsTable entities={[]} />));
expect(await findByText('No documents to show')).toBeInTheDocument();
});
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,46 +20,34 @@ import { generatePath } from 'react-router-dom';
import { IconButton, Tooltip } from '@material-ui/core';
import ShareIcon from '@material-ui/icons/Share';
import {
Content,
ContentHeader,
SupportButton,
Table,
EmptyState,
Button,
SubvalueCell,
Link,
} from '@backstage/core';
import { Table, EmptyState, Button, SubvalueCell, Link } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { isOwnerOf } from '@backstage/plugin-catalog-react';
import { rootDocsRouteRef } from '../../plugin';
import { useOwnUser } from '../hooks';
export const OwnedContent = ({
export const DocsTable = ({
entities,
title,
}: {
entities: Entity[] | undefined;
title?: string | undefined;
}) => {
const [, copyToClipboard] = useCopyToClipboard();
const { value: user } = useOwnUser();
if (!entities || !user) return null;
if (!entities) return null;
const ownedDocuments = entities
.filter((entity: Entity) => isOwnerOf(user, entity))
.map(entity => {
return {
const documents = entities.map(entity => {
return {
name: entity.metadata.name,
description: entity.metadata.description,
owner: entity?.spec?.owner,
type: entity?.spec?.type,
docsUrl: generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
description: entity.metadata.description,
owner: entity?.spec?.owner,
type: entity?.spec?.type,
docsUrl: generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
};
});
}),
};
});
const columns = [
{
@@ -99,19 +87,17 @@ export const OwnedContent = ({
];
return (
<Content>
<ContentHeader
title="Owned documents"
description="Access your documentation."
>
<SupportButton>Discover documentation you own.</SupportButton>
</ContentHeader>
{ownedDocuments && ownedDocuments.length > 0 ? (
<>
{documents && documents.length > 0 ? (
<Table
options={{ paging: true, pageSize: 20, search: true }}
data={ownedDocuments}
data={documents}
columns={columns}
title={`Owned (${ownedDocuments.length})`}
title={
title
? `${title} (${documents.length})`
: `All (${documents.length})`
}
/>
) : (
<EmptyState
@@ -130,6 +116,6 @@ export const OwnedContent = ({
}
/>
)}
</Content>
</>
);
};
@@ -1,73 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 from 'react';
import { generatePath } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import {
Button,
Content,
ContentHeader,
SupportButton,
ItemCardGrid,
ItemCardHeader,
} from '@backstage/core';
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
import { rootDocsRouteRef } from '../../plugin';
export const OverviewContent = ({
entities,
}: {
entities: Entity[] | undefined;
}) => {
if (!entities) return null;
return (
<Content>
<ContentHeader
title="Overview"
description="Explore your internal technical ecosystem through documentation."
>
<SupportButton>Discover documentation in your ecosystem.</SupportButton>
</ContentHeader>
<ItemCardGrid data-testid="docs-explore">
{!entities?.length
? null
: entities.map((entity, index: number) => (
<Card key={index}>
<CardMedia>
<ItemCardHeader title={entity.metadata.name} />
</CardMedia>
<CardContent>{entity.metadata.description}</CardContent>
<CardActions>
<Button
to={generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
})}
color="primary"
>
Read Docs
</Button>
</CardActions>
</Card>
))}
</ItemCardGrid>
</Content>
);
};
@@ -0,0 +1,83 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ApiProvider, ApiRegistry } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { TechDocsCustomHome, WidgetType } from './TechDocsCustomHome';
describe('TechDocsCustomHome', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: async () => ({ items: [] }),
};
const apiRegistry = ApiRegistry.with(catalogApiRef, catalogApi);
it('should render a TechDocs home page', async () => {
const tabsConfig = [
{
label: 'First Tab',
widgets: [
{
title: 'First Tab',
description: 'First Tab Description',
widgetType: 'DocsCardGrid' as WidgetType,
filterPredicate: () => true,
},
],
},
{
label: 'Second Tab ',
widgets: [
{
title: 'Second Tab',
description: 'Second Tab Description',
widgetType: 'DocsTable' as WidgetType,
filterPredicate: () => true,
},
],
},
];
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsCustomHome tabsConfig={tabsConfig} />
</ApiProvider>,
);
// Header
expect(await screen.findByText('Documentation')).toBeInTheDocument();
expect(
await screen.findByText(/Documentation available in Backstage/i),
).toBeInTheDocument();
// Explore Content
expect(await screen.findByTestId('docs-explore')).toBeDefined();
// Tabs
expect(await screen.findAllByText('First Tab')).toHaveLength(2);
expect(await screen.findByText('Second Tab')).toBeInTheDocument();
expect(
await screen.findByText('First Tab Description'),
).toBeInTheDocument();
(await screen.findByText('Second Tab')).click();
expect(
await screen.findByText('Second Tab Description'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,169 @@
/*
* Copyright 2021 Spotify AB
*
* 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, { useState } from 'react';
import { useAsync } from 'react-use';
import { makeStyles } from '@material-ui/core';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import {
CodeSnippet,
Content,
ConfigApi,
configApiRef,
Header,
HeaderTabs,
Page,
Progress,
useApi,
WarningPanel,
SupportButton,
ContentHeader,
} from '@backstage/core';
import { DocsTable } from './DocsTable';
import { DocsCardGrid } from './DocsCardGrid';
const widgets = {
DocsTable: DocsTable,
DocsCardGrid: DocsCardGrid,
};
export type WidgetType = 'DocsCardGrid' | 'DocsTable';
export interface WidgetConfig {
title: string;
description: string;
widgetType: WidgetType;
widgetMaxHeight?: string;
filterPredicate: (entity: Entity) => boolean;
}
export interface TabConfig {
label: string;
widgets: WidgetConfig[];
}
export type TabsConfig = TabConfig[];
const CustomWidget = ({
config,
entities,
index,
}: {
config: WidgetConfig;
entities: Entity[];
index: number;
}) => {
const useStyles = makeStyles({
widgetContainer: {
maxHeight: config.widgetMaxHeight || 'inherit',
marginBottom: '2rem',
overflow: 'auto',
},
});
const classes = useStyles();
const Widget = widgets[config.widgetType];
const shownEntities = entities.filter(config.filterPredicate);
return (
<>
<ContentHeader title={config.title} description={config.description}>
{index === 0 ? (
<SupportButton>
Discover documentation in your ecosystem.
</SupportButton>
) : null}
</ContentHeader>
<div className={classes.widgetContainer}>
<Widget entities={shownEntities} />
</div>
</>
);
};
export const TechDocsCustomHome = ({
tabsConfig,
}: {
tabsConfig: TabsConfig;
}) => {
const [selectedTab, setSelectedTab] = useState<number>(0);
const catalogApi: CatalogApi = useApi(catalogApiRef);
const configApi: ConfigApi = useApi(configApiRef);
const { value: entities, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
return response.items.filter((entity: Entity) => {
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
});
});
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
const currentTabConfig = tabsConfig[selectedTab];
if (loading) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<Content>
<Progress />
</Content>
</Page>
);
}
if (error) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<Content>
<WarningPanel
severity="error"
title="Could not load available documentation."
>
<CodeSnippet language="text" text={error.toString()} />
</WarningPanel>
</Content>
</Page>
);
}
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<HeaderTabs
selectedIndex={selectedTab}
onChange={index => setSelectedTab(index)}
tabs={tabsConfig.map(({ label }, index) => ({
id: index.toString(),
label,
}))}
/>
<Content>
{currentTabConfig.widgets.map((config, index) => (
<CustomWidget
key={index}
config={config}
entities={!!entities ? entities : []}
index={index}
/>
))}
</Content>
</Page>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -27,6 +27,30 @@ import { screen } from '@testing-library/react';
import React from 'react';
import { TechDocsHome } from './TechDocsHome';
jest.mock('../hooks', () => ({
useOwnUser: () => {
return {
value: {
apiVersion: 'version',
kind: 'User',
metadata: {
name: 'owned',
namespace: 'default',
},
relations: [
{
target: {
kind: 'TestKind',
name: 'testName',
},
type: 'ownerOf',
},
],
},
};
},
}));
describe('TechDocs Home', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: async () => ({ items: [] }),
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,88 +14,44 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react';
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import {
CodeSnippet,
ConfigApi,
configApiRef,
Content,
Header,
HeaderTabs,
Page,
Progress,
useApi,
WarningPanel,
} from '@backstage/core';
import { OverviewContent } from './OverviewContent';
import { OwnedContent } from './OwnedContent';
import { useOwnUser } from '../hooks';
import { isOwnerOf } from '@backstage/plugin-catalog-react';
import { WidgetType, TechDocsCustomHome } from './TechDocsCustomHome';
export const TechDocsHome = () => {
const [selectedTab, setSelectedTab] = useState<number>(0);
const catalogApi: CatalogApi = useApi(catalogApiRef);
const configApi: ConfigApi = useApi(configApiRef);
const { value: user } = useOwnUser();
const tabs = [{ label: 'Overview' }, { label: 'Owned Documents' }];
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
return response.items.filter((entity: Entity) => {
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
});
});
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
if (loading) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<Content>
<Progress />
</Content>
</Page>
);
}
if (error) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<Content>
<WarningPanel
severity="error"
title="Could not load available documentation."
>
<CodeSnippet language="text" text={error.toString()} />
</WarningPanel>
</Content>
</Page>
);
}
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<HeaderTabs
selectedIndex={selectedTab}
onChange={index => setSelectedTab(index)}
tabs={tabs.map(({ label }, index) => ({
id: index.toString(),
label,
}))}
/>
{selectedTab === 0 ? (
<OverviewContent entities={value} />
) : (
<OwnedContent entities={value} />
)}
</Page>
);
const tabsConfig = [
{
label: 'Overview',
widgets: [
{
title: 'Overview',
description:
'Explore your internal technical ecosystem through documentation.',
widgetType: 'DocsCardGrid' as WidgetType,
filterPredicate: () => true,
},
],
},
{
label: 'Owned',
widgets: [
{
title: 'Owned documents',
description: 'Access your documentation.',
widgetType: 'DocsTable' as WidgetType,
filterPredicate: (entity: Entity) => {
if (!user) {
return false;
}
return isOwnerOf(user, entity);
},
},
],
},
];
return <TechDocsCustomHome tabsConfig={tabsConfig} />;
};
+5
View File
@@ -19,7 +19,12 @@ export {
techdocsPlugin as plugin,
TechdocsPage,
EntityTechdocsContent,
DocsCardGrid,
DocsTable,
TechDocsCustomHome,
TechDocsReaderPage,
} from './plugin';
export { Router, EmbeddedDocsRouter } from './Router';
export * from './reader';
export * from './api';
export type { WidgetType } from './home/components/TechDocsCustomHome';
+39
View File
@@ -37,6 +37,7 @@ import {
discoveryApiRef,
identityApiRef,
createRoutableExtension,
createComponentExtension,
} from '@backstage/core';
import {
techdocsStorageApiRef,
@@ -111,3 +112,41 @@ export const EntityTechdocsContent = techdocsPlugin.provide(
mountPoint: rootCatalogDocsRouteRef,
}),
);
// takes a list of entities and renders documentation cards
export const DocsCardGrid = techdocsPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./home/components/DocsCardGrid').then(m => m.DocsCardGrid),
},
}),
);
// takes a list of entities and renders table listing documentation
export const DocsTable = techdocsPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./home/components/DocsTable').then(m => m.DocsTable),
},
}),
);
// takes a custom tabs config object and renders a documentation landing page
export const TechDocsCustomHome = techdocsPlugin.provide(
createRoutableExtension({
component: () =>
import('./home/components/TechDocsCustomHome').then(
m => m.TechDocsCustomHome,
),
mountPoint: rootRouteRef,
}),
);
export const TechDocsReaderPage = techdocsPlugin.provide(
createRoutableExtension({
component: () =>
import('./reader/components/TechDocsPage').then(m => m.TechDocsPage),
mountPoint: rootDocsRouteRef,
}),
);