TechDocs: browse metadata (#2682)

* feat(techdocs): TechDocsPageWrapper to take in labels prop and use TechDocsHeader component

* feat(techdocs): new TechDocsHeader component as wrapper around Header from core

* feat(techdocs): TechDocsPage metadata

* feat(techdocs): TechDocsMetadata class responsible for reading metadata from generated techdocs metadata json file

* fix(techdocs): delete TechDocsPageWrapper component

* fix(app-config): delete /docs from request url

* feat(techdocs): TechDocsHeader with labels to browse metadata

* fix(techdocs): move metadata to backend plugin

* feat(techdocs-backend): introduce two new metadata routes

* feat(techdocs): new techdocs api to be responsible to request metadata from backend

* feat(techdocs): register new api for plugin

* fix(techdocs): reader to take in onReady prop

* fix(techdocs): TechDocsHome component to use its own header

* fix(techdocs): TechDocsPage responsible to get metadata and pass it down to the TechDocsHeader

* fix(techdocs): component tests for both TechDocsHeader and TechDocsPage

* fix(techdocs): adjust api to use /docs in url since it was taken away in requestURL

* fix(tests): move assertion into act

* fix(techdocs): rename TechDocsHeader to TechDocsPageHeader

* fix(techdocs): rename TechDocsHeader to TechDocsPageHeader

* fix(techdocs-backend): add some logs

* Update plugins/techdocs/src/reader/components/TechDocsPage.test.tsx

* delete unused import
This commit is contained in:
Emma Indal
2020-10-01 10:12:24 +02:00
committed by GitHub
parent 44b6533a1b
commit 497675538c
13 changed files with 468 additions and 108 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`,
`${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`,
);
});
@@ -36,7 +36,7 @@ describe('TechDocsStorageApi', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`,
`${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`,
);
});
});
+32 -2
View File
@@ -23,6 +23,11 @@ export const techdocsStorageApiRef = createApiRef<TechDocsStorageApi>({
description: 'Used to make requests towards the techdocs storage',
});
export const techdocsApiRef = createApiRef<TechDocsApi>({
id: 'plugin.techdocs.service',
description: 'Used to make requests towards techdocs API',
});
export interface TechDocsStorage {
getEntityDocs(entityId: ParsedEntityId, path: string): Promise<string>;
getBaseUrl(
@@ -32,6 +37,31 @@ export interface TechDocsStorage {
): string;
}
export interface TechDocs {
getMetadata(metadataType: string, entityId: ParsedEntityId): Promise<string>;
}
export class TechDocsApi implements TechDocs {
public apiOrigin: string;
constructor({ apiOrigin }: { apiOrigin: string }) {
this.apiOrigin = apiOrigin;
}
async getMetadata(metadataType: string, entityId: ParsedEntityId) {
const { kind, namespace, name } = entityId;
const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${kind}/${
namespace ? namespace : 'default'
}/${name}`;
const request = await fetch(`${requestUrl}`);
const res = await request.json();
return res;
}
}
export class TechDocsStorageApi implements TechDocsStorage {
public apiOrigin: string;
@@ -42,7 +72,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
async getEntityDocs(entityId: ParsedEntityId, path: string) {
const { kind, namespace, name } = entityId;
const url = `${this.apiOrigin}/${kind}/${
const url = `${this.apiOrigin}/docs/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`;
@@ -66,7 +96,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
return new URL(
oldBaseUrl,
`${this.apiOrigin}/${kind}/${
`${this.apiOrigin}/docs/${kind}/${
namespace ? namespace : 'default'
}/${name}/${path}`,
).toString();
+14 -1
View File
@@ -35,7 +35,12 @@ import {
createApiFactory,
configApiRef,
} from '@backstage/core';
import { techdocsStorageApiRef, TechDocsStorageApi } from './api';
import {
techdocsStorageApiRef,
TechDocsStorageApi,
techdocsApiRef,
TechDocsApi,
} from './api';
export const rootRouteRef = createRouteRef({
path: '',
@@ -63,5 +68,13 @@ export const plugin = createPlugin({
apiOrigin: configApi.getString('techdocs.requestUrl'),
}),
}),
createApiFactory({
api: techdocsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
new TechDocsApi({
apiOrigin: configApi.getString('techdocs.requestUrl'),
}),
}),
],
});
@@ -39,9 +39,10 @@ import { TechDocsNotFound } from './TechDocsNotFound';
type Props = {
entityId: ParsedEntityId;
onReady?: () => void;
};
export const Reader = ({ entityId }: Props) => {
export const Reader = ({ entityId, onReady }: Props) => {
const { kind, namespace, name } = entityId;
const { '*': path } = useParams();
const theme = useTheme<BackstageTheme>();
@@ -58,7 +59,9 @@ export const Reader = ({ entityId }: Props) => {
if (!shadowRoot || loading || error) {
return; // Shadow DOM isn't ready / It's not ready / Docs was not found
}
if (onReady) {
onReady();
}
// Pre-render
const transformedElement = transformer(value as string, [
sanitizeDOM(),
@@ -143,6 +146,7 @@ export const Reader = ({ entityId }: Props) => {
navigate,
techdocsStorageApi,
theme,
onReady,
]);
if (error) {
@@ -18,8 +18,15 @@ import React from 'react';
import { useAsync } from 'react-use';
import { useNavigate, generatePath } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import { ItemCard, Progress, useApi } from '@backstage/core';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import {
ItemCard,
Progress,
useApi,
Content,
Page,
pageTheme,
Header,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { rootDocsRouteRef } from '../../plugin';
@@ -36,53 +43,62 @@ export const TechDocsHome = () => {
if (loading) {
return (
<TechDocsPageWrapper
title="Documentation"
subtitle="Documentation available in Backstage"
>
<Progress />
</TechDocsPageWrapper>
<Page theme={pageTheme.documentation}>
<Header
title="Documentation"
subtitle="Documentation available in Backstage"
/>
<Content>
<Progress />
</Content>
</Page>
);
}
if (error) {
return (
<TechDocsPageWrapper
title="Documentation"
subtitle="Documentation available in Backstage"
>
<p>{error.message}</p>
</TechDocsPageWrapper>
<Page theme={pageTheme.documentation}>
<Header
title="Documentation"
subtitle="Documentation available in Backstage"
/>
<Content>
<p>{error.message}</p>
</Content>
</Page>
);
}
return (
<TechDocsPageWrapper
title="Documentation"
subtitle="Documentation available in Backstage"
>
<Grid container data-testid="docs-explore">
{value?.length
? value.map((entity, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
entityId: `${entity.kind}:${
entity.metadata.namespace ?? ''
}:${entity.metadata.name}`,
}),
)
}
title={entity.metadata.name}
label="Read Docs"
description={entity.metadata.description}
/>
</Grid>
))
: null}
</Grid>
</TechDocsPageWrapper>
<Page theme={pageTheme.documentation}>
<Header
title="Documentation"
subtitle="Documentation available in Backstage"
/>
<Content>
<Grid container data-testid="docs-explore">
{value?.length
? value.map((entity, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
entityId: `${entity.kind}:${
entity.metadata.namespace ?? ''
}:${entity.metadata.name}`,
}),
)
}
title={entity.metadata.name}
label="Read Docs"
description={entity.metadata.description}
/>
</Grid>
))
: null}
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,77 @@
/*
* 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 { TechDocsPage } from './TechDocsPage';
import { render, act } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiRegistry, ApiProvider } from '@backstage/core-api';
import {
techdocsApiRef,
TechDocsApi,
techdocsStorageApiRef,
TechDocsStorageApi,
} from '../../api';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
...actual,
useParams: jest.fn(),
};
});
jest.mock('./TechDocsPageHeader', () => {
return {
__esModule: true,
TechDocsPageHeader: () => <div />,
};
});
const { useParams }: { useParams: jest.Mock } = jest.requireMock(
'react-router-dom',
);
describe('<TechDocsPage />', () => {
it('should render techdocs page', async () => {
useParams.mockReturnValue({
entityId: 'Component::backstage',
});
const techDocsApi: Partial<TechDocsApi> = {
getMetadata: () => Promise.resolve([]),
};
const techDocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): string => '',
};
const apiRegistry = ApiRegistry.from([
[techdocsApiRef, techDocsApi],
[techdocsStorageApiRef, techDocsStorageApi],
]);
await act(async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsPage />
</ApiProvider>,
),
);
expect(rendered.getByTestId('techdocs-content')).toBeInTheDocument();
});
});
});
@@ -14,26 +14,60 @@
* limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import { Content, Page, pageTheme, useApi } from '@backstage/core';
import { Reader } from './Reader';
import { useAsync } from 'react-use';
import { TechDocsPageHeader } from './TechDocsPageHeader';
import { techdocsApiRef } from '../../api';
export const TechDocsPage = () => {
const [documentReady, setDocumentReady] = useState<boolean>(false);
const { entityId } = useParams();
const [kind, namespace, name] = entityId.split(':');
const techDocsApi = useApi(techdocsApiRef);
const mkdocsMetadataRequest = useAsync(() => {
if (documentReady) {
return techDocsApi.getMetadata('mkdocs', { kind, namespace, name });
}
return Promise.resolve({ loading: true });
}, [kind, namespace, name, techDocsApi, documentReady]);
const entityMetadataRequest = useAsync(() => {
return techDocsApi.getMetadata('entity', { kind, namespace, name });
}, [kind, namespace, name, techDocsApi]);
const onReady = () => {
setDocumentReady(true);
};
return (
<TechDocsPageWrapper title={name} subtitle={name}>
<Reader
<Page theme={pageTheme.documentation}>
<TechDocsPageHeader
metadataRequest={{
mkdocs: mkdocsMetadataRequest,
entity: entityMetadataRequest,
}}
entityId={{
kind,
namespace,
name,
}}
/>
</TechDocsPageWrapper>
<Content data-testid="techdocs-content">
<Reader
onReady={onReady}
entityId={{
kind,
namespace,
name,
}}
/>
</Content>
</Page>
);
};
@@ -0,0 +1,87 @@
/*
* 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 { TechDocsPageHeader } from './TechDocsPageHeader';
import { render, act } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
describe('<TechDocsPageHeader />', () => {
it('should render a techdocs page header', async () => {
await act(async () => {
const rendered = render(
wrapInTestApp(
<TechDocsPageHeader
entityId={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
}}
metadataRequest={{
entity: {
loading: false,
value: {
locationMetadata: {
type: 'github',
target: 'https://example.com/',
},
spec: {
owner: 'test',
},
},
},
mkdocs: {
loading: false,
value: {
site_name: 'test-site-name',
site_description: 'test-site-desc',
},
},
}}
/>,
),
);
expect(rendered.container.innerHTML).toContain('header');
expect(rendered.getByText('test-site-name')).toBeDefined();
expect(rendered.getByText('test-site-desc')).toBeDefined();
});
});
it('should render a techdocs page header even if metadata is missing', async () => {
await act(async () => {
const rendered = render(
wrapInTestApp(
<TechDocsPageHeader
entityId={{
kind: 'test',
name: 'test-name',
namespace: 'test-namespace',
}}
metadataRequest={{
entity: {
loading: false,
},
mkdocs: {
loading: false,
},
}}
/>,
),
);
expect(rendered.container.innerHTML).toContain('header');
});
});
});
@@ -0,0 +1,88 @@
/*
* 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 GitHubIcon from '@material-ui/icons/GitHub';
import { Header, HeaderLabel, Link } from '@backstage/core';
import { CircularProgress } from '@material-ui/core';
import { ParsedEntityId } from '../../types';
import { AsyncState } from 'react-use/lib/useAsync';
type TechDocsPageHeaderProps = {
entityId: ParsedEntityId;
metadataRequest: {
entity: AsyncState<any>;
mkdocs: AsyncState<any>;
};
};
export const TechDocsPageHeader = ({
entityId,
metadataRequest,
}: TechDocsPageHeaderProps) => {
const { mkdocs: mkdocsMetadata, entity: entityMetadata } = metadataRequest;
const { value: mkDocsMetadataValues } = mkdocsMetadata;
const { value: entityMetadataValues } = entityMetadata;
const { kind, name } = entityId;
const { site_name: siteName, site_description: siteDescription } =
mkDocsMetadataValues || {};
const {
locationMetadata,
spec: { owner, lifecycle },
} = entityMetadataValues || { spec: {} };
const labels = (
<>
<HeaderLabel
label="Component"
value={
<Link style={{ color: '#fff' }} to={`/catalog/${kind}/${name}`}>
{name}
</Link>
}
/>
{owner ? <HeaderLabel label="Site Owner" value={owner} /> : null}
{lifecycle ? <HeaderLabel label="Lifecycle" value={lifecycle} /> : null}
{locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file' ? (
<HeaderLabel
label=""
value={
<a href={locationMetadata.target} target="_blank">
<GitHubIcon style={{ marginTop: '-25px', fill: '#fff' }} />
</a>
}
/>
) : null}
</>
);
return (
<Header
title={siteName ? siteName : <CircularProgress />}
subtitle={
siteDescription && siteDescription !== 'None' ? siteDescription : ''
}
>
{labels}
</Header>
);
};
@@ -1,33 +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 { TechDocsPageWrapper } from './TechDocsPageWrapper';
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
describe('TechDocs Page Wrapper', () => {
it('should render a TechDocs Page Wrapper', async () => {
const { queryByText } = render(
wrapInTestApp(
<TechDocsPageWrapper title="test-title" subtitle="test-subtitle">
Test
</TechDocsPageWrapper>,
),
);
expect(queryByText(/test-title/i)).toBeInTheDocument();
expect(queryByText(/test-subtitle/i)).toBeInTheDocument();
});
});
@@ -1,37 +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 { Header, Content, Page, pageTheme } from '@backstage/core';
type TechDocsPageWrapperProps = {
title: string;
subtitle: string;
children: any;
};
export const TechDocsPageWrapper = ({
children,
title,
subtitle,
}: TechDocsPageWrapperProps) => {
return (
<Page theme={pageTheme.documentation}>
<Header title={title} subtitle={subtitle} />
<Content>{children}</Content>
</Page>
);
};