TechDocs Homepage (#4401)

* add tabs to homepage

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add new overview content component

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add new owned content component

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add types

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add test for owned content component

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add test for overview content

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* document name to link and change copy to clipboard link

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add changeset

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* rename changeset file

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* fix types

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* move home components out from reader dir to its own dir

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* Update plugins/techdocs/src/reader/components/OverviewContent.tsx

Co-authored-by: Himanshu Mishra <himanshu@orkohunter.net>
Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* enable search in table

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add docs icon to core icons

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add example TechDocs support button config

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* use isOwnerOff and useOwnUser to see documents that are owned via group membership

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* update tests

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* cleanup imports

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* leave techdocs support config out of this PR

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* prettieeeeeer

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* copy paste useOwnUser hook over to techdocs plugin

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* add changeset

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

* delete unused package

Signed-off-by: Emma Indal <emma.indahl@gmail.com>

Co-authored-by: Himanshu Mishra <himanshu@orkohunter.net>
This commit is contained in:
Emma Indal
2021-03-11 19:23:32 +01:00
committed by GitHub
parent 3ece07ee8d
commit c8b54c3702
14 changed files with 482 additions and 40 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Extended TechDocs HomePage with owned documents
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-api': patch
---
Added new Docs Icon to Core Icons
+4
View File
@@ -24,6 +24,8 @@ import MuiHelpIcon from '@material-ui/icons/Help';
import MuiPeopleIcon from '@material-ui/icons/People';
import MuiPersonIcon from '@material-ui/icons/Person';
import MuiWarningIcon from '@material-ui/icons/Warning';
import MuiDocsIcon from '@material-ui/icons/Description';
import React from 'react';
import { useApp } from '../app/AppContext';
import { IconComponent, IconComponentMap, SystemIconKey } from './types';
@@ -38,6 +40,7 @@ export const defaultSystemIcons: IconComponentMap = {
help: MuiHelpIcon,
user: MuiPersonIcon,
warning: MuiWarningIcon,
docs: MuiDocsIcon,
};
const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
@@ -58,3 +61,4 @@ export const GroupIcon = overridableSystemIcon('group');
export const HelpIcon = overridableSystemIcon('help');
export const UserIcon = overridableSystemIcon('user');
export const WarningIcon = overridableSystemIcon('warning');
export const DocsIcon = overridableSystemIcon('docs');
+2 -1
View File
@@ -26,7 +26,8 @@ export type SystemIconKey =
| 'group'
| 'help'
| 'user'
| 'warning';
| 'warning'
| 'docs';
export type IconComponent = ComponentType<SvgIconProps>;
export type IconKey = SystemIconKey | string;
@@ -8,4 +8,4 @@ metadata:
spec:
type: service
lifecycle: experimental
owner: documented@example.com
owner: user:guest
+1 -1
View File
@@ -24,7 +24,7 @@ import {
rootDocsRouteRef,
rootCatalogDocsRouteRef,
} from './plugin';
import { TechDocsHome } from './reader/components/TechDocsHome';
import { TechDocsHome } from './home/components/TechDocsHome';
import { TechDocsPage } from './reader/components/TechDocsPage';
import { EntityPageDocs } from './EntityPageDocs';
@@ -0,0 +1,62 @@
/*
* 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 { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { OverviewContent } from './OverviewContent';
describe('TechDocs Overview Content', () => {
it('should render all TechDocs Documents', async () => {
const { findByText } = render(
wrapInTestApp(
<OverviewContent
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
},
spec: {
owner: 'techdocs@example.com',
},
},
{
apiVersion: 'version',
kind: 'TestKind2',
metadata: {
name: 'testName2',
},
spec: {
owner: 'not-owned@example.com',
},
},
]}
/>,
),
);
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,73 @@
/*
* 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,109 @@
/*
* 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 { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { OwnedContent } from './OwnedContent';
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(
wrapInTestApp(
<OwnedContent
entities={[
{
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
},
spec: {
owner: 'user:owned',
},
relations: [
{
target: {
kind: 'user',
namespace: 'default',
name: 'owned',
},
type: 'ownedBy',
},
],
},
{
apiVersion: 'version',
kind: 'TestKind2',
metadata: {
name: 'testName2',
},
spec: {
owner: 'not-owned@example.com',
},
relations: [
{
target: {
kind: 'user',
namespace: 'default',
name: 'not-owned',
},
type: 'ownedBy',
},
],
},
]}
/>,
),
);
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();
});
it('should render empty state if no owned documents exist', async () => {
const { findByText } = render(
wrapInTestApp(<OwnedContent entities={[]} />),
);
expect(await findByText('No documents to show')).toBeInTheDocument();
});
});
@@ -0,0 +1,135 @@
/*
* 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 { useCopyToClipboard } from 'react-use';
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 { Entity } from '@backstage/catalog-model';
import { isOwnerOf } from '@backstage/plugin-catalog-react';
import { rootDocsRouteRef } from '../../plugin';
import { useOwnUser } from '../hooks';
export const OwnedContent = ({
entities,
}: {
entities: Entity[] | undefined;
}) => {
const [, copyToClipboard] = useCopyToClipboard();
const { value: user } = useOwnUser();
if (!entities || !user) return null;
const ownedDocuments = entities
.filter((entity: Entity) => isOwnerOf(user, entity))
.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,
}),
};
});
const columns = [
{
title: 'Document',
field: 'name',
highlight: true,
render: (row: any): React.ReactNode => (
<SubvalueCell
value={<Link to={row.docsUrl}>{row.name}</Link>}
subvalue={row.description}
/>
),
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Type',
field: 'type',
},
{
title: 'Actions',
width: '10%',
render: (row: any) => (
<Tooltip title="Click to copy documentation link to clipboard">
<IconButton
onClick={() =>
copyToClipboard(`${window.location.origin}/${row.docsUrl}`)
}
>
<ShareIcon />
</IconButton>
</Tooltip>
),
},
];
return (
<Content>
<ContentHeader
title="Owned documents"
description="Access your documentation."
>
<SupportButton>Discover documentation you own.</SupportButton>
</ContentHeader>
{ownedDocuments && ownedDocuments.length > 0 ? (
<Table
options={{ paging: true, pageSize: 20, search: true }}
data={ownedDocuments}
columns={columns}
title={`Owned (${ownedDocuments.length})`}
/>
) : (
<EmptyState
missing="data"
title="No documents to show"
description="Create your own document. Check out our Getting Started Information"
action={
<Button
color="primary"
href="#"
to="https://backstage.io/docs/features/techdocs/getting-started"
variant="contained"
>
DOCS
</Button>
}
/>
)}
</Content>
);
};
@@ -14,31 +14,34 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import {
Button,
CodeSnippet,
Content,
Header,
ItemCardGrid,
ItemCardHeader,
HeaderTabs,
Page,
Progress,
useApi,
WarningPanel,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
import React from 'react';
import { generatePath } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
import { OverviewContent } from './OverviewContent';
import { OwnedContent } from './OwnedContent';
export const TechDocsHome = () => {
const catalogApi = useApi(catalogApiRef);
const [selectedTab, setSelectedTab] = useState<number>(0);
const catalogApi: CatalogApi = useApi(catalogApiRef);
const tabs = [{ label: 'Overview' }, { label: 'Owned Documents' }];
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
return response.items.filter(entity => {
return response.items.filter((entity: Entity) => {
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
});
});
@@ -82,32 +85,19 @@ export const TechDocsHome = () => {
title="Documentation"
subtitle="Documentation available in Backstage"
/>
<Content>
<ItemCardGrid data-testid="docs-explore">
{!value?.length
? null
: value.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>
<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>
);
};
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { useOwnUser } from './useOwnUser';
@@ -0,0 +1,41 @@
/*
* 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 { UserEntity } from '@backstage/catalog-model';
import { identityApiRef, useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { useAsync } from 'react-use';
import { AsyncState } from 'react-use/lib/useAsync';
/**
* Get the catalog User entity (if any) that matches the logged-in user.
*/
export function useOwnUser(): AsyncState<UserEntity | undefined> {
const catalogApi = useApi(catalogApiRef);
const identityApi = useApi(identityApiRef);
// TODO: get the full entity (or at least the full entity name) from the
// identityApi
return useAsync(
() =>
catalogApi.getEntityByName({
kind: 'User',
namespace: 'default',
name: identityApi.getUserId(),
}) as Promise<UserEntity | undefined>,
[catalogApi, identityApi],
);
}