TechDocs: show outdated docs and asnyc build new
Signed-off-by: Chongyang Adrian, Ke <ftt.adrian.ke@grabtaxi.com>
This commit is contained in:
@@ -37,7 +37,7 @@ describe('TechDocsStorageApi', () => {
|
||||
await expect(
|
||||
storageApi.getBaseUrl('test.js', mockEntity, ''),
|
||||
).resolves.toEqual(
|
||||
`${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
|
||||
`${mockBaseUrl}/static/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('TechDocsStorageApi', () => {
|
||||
await expect(
|
||||
storageApi.getBaseUrl('test/', mockEntity, ''),
|
||||
).resolves.toEqual(
|
||||
`${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
|
||||
`${mockBaseUrl}/static/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core';
|
||||
import { Config } from '@backstage/config';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { TechDocsMetadata } from './types';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
export const techdocsStorageApiRef = createApiRef<TechDocsStorageApi>({
|
||||
id: 'plugin.techdocs.storageservice',
|
||||
@@ -31,6 +32,7 @@ export const techdocsApiRef = createApiRef<TechDocsApi>({
|
||||
|
||||
export interface TechDocsStorage {
|
||||
getEntityDocs(entityId: EntityName, path: string): Promise<string>;
|
||||
syncEntityDocs(entityId: EntityName): Promise<boolean>;
|
||||
getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: EntityName,
|
||||
@@ -153,6 +155,17 @@ export class TechDocsStorageApi implements TechDocsStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async getStorageUrl() {
|
||||
return (
|
||||
this.configApi.getOptionalString('techdocs.storageUrl') ??
|
||||
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
|
||||
);
|
||||
}
|
||||
|
||||
async getBuilder() {
|
||||
return this.configApi.getString('techdocs.builder');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch HTML content as text for an individual docs page in an entity's docs site.
|
||||
*
|
||||
@@ -164,8 +177,8 @@ export class TechDocsStorageApi implements TechDocsStorage {
|
||||
async getEntityDocs(entityId: EntityName, path: string) {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`;
|
||||
const storageUrl = await this.getStorageUrl();
|
||||
const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
|
||||
const token = await this.identityApi.getIdToken();
|
||||
|
||||
const request = await fetch(
|
||||
@@ -184,7 +197,7 @@ export class TechDocsStorageApi implements TechDocsStorage {
|
||||
errorMessage +=
|
||||
'This could be because there is no index.md file in the root of the docs directory of this repository.';
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
throw new NotFoundError(errorMessage);
|
||||
case 500:
|
||||
errorMessage =
|
||||
'Could not generate documentation or an error in the TechDocs backend. ';
|
||||
@@ -197,6 +210,46 @@ export class TechDocsStorageApi implements TechDocsStorage {
|
||||
return request.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if docs are on the latest version and trigger rebuild if not
|
||||
*
|
||||
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
|
||||
* @returns {boolean} Whether documents are currently synchronized to newest version
|
||||
* @throws {Error} Throws error on error from sync endpoint in Techdocs Backend
|
||||
*/
|
||||
async syncEntityDocs(entityId: EntityName) {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
|
||||
const token = await this.identityApi.getIdToken();
|
||||
let request;
|
||||
let attempts: number = 0;
|
||||
// retry if request times out, up to 5 times
|
||||
// can happen due to docs taking too long to generate
|
||||
while (!request || (request.status === 408 && attempts < 5)) {
|
||||
attempts++;
|
||||
request = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
switch (request.status) {
|
||||
case 404:
|
||||
throw new NotFoundError((await request.json()).error);
|
||||
case 200:
|
||||
case 201:
|
||||
case 304:
|
||||
return true;
|
||||
// for timeout and misc errors, handle without error to allow viewing older docs
|
||||
// if older docs not available,
|
||||
// Reader will show 404 error coming from getEntityDocs
|
||||
case 408:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: EntityName,
|
||||
@@ -205,10 +258,9 @@ export class TechDocsStorageApi implements TechDocsStorage {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
|
||||
return new URL(
|
||||
oldBaseUrl,
|
||||
`${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`,
|
||||
`${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`,
|
||||
).toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,15 @@ import { EntityName } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
<<<<<<< HEAD
|
||||
import React, { useEffect, useState } from 'react';
|
||||
=======
|
||||
>>>>>>> 3ab0f7d4f (TechDocs: show outdated docs and asnyc build new)
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
import { useShadowDom } from '../hooks';
|
||||
import transformer, {
|
||||
addBaseUrl,
|
||||
addLinkClickListener,
|
||||
@@ -46,13 +50,45 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
<<<<<<< HEAD
|
||||
const [shadowDomRef, shadowRoot] = useShadowDom();
|
||||
const [sidebars, setSidebars] = useState<HTMLElement[]>();
|
||||
=======
|
||||
>>>>>>> 3ab0f7d4f (TechDocs: show outdated docs and asnyc build new)
|
||||
const navigate = useNavigate();
|
||||
const shadowDomRef = useRef(null);
|
||||
const [loadedPath, setLoadedPath] = useState('');
|
||||
const [atInitialLoad, setAtInitialLoad] = useState(true);
|
||||
const [newerDocsExist, setNewerDocsExist] = useState(false);
|
||||
|
||||
const { value, loading, error } = useAsync(async () => {
|
||||
const {
|
||||
value: isSynced,
|
||||
loading: syncInProgress,
|
||||
error: syncError,
|
||||
} = useAsync(async () => {
|
||||
// Attempt to sync only if `techdocs.builder` in app config is set to 'local'
|
||||
if ((await techdocsStorageApi.getBuilder()) !== 'local') {
|
||||
return Promise.resolve({
|
||||
value: true,
|
||||
loading: null,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return techdocsStorageApi.syncEntityDocs({ kind, namespace, name });
|
||||
});
|
||||
|
||||
const {
|
||||
value: rawPage,
|
||||
loading: docLoading,
|
||||
error: docLoadError,
|
||||
} = useAsync(async () => {
|
||||
// do not automatically load same page again if URL has not changed,
|
||||
// happens when generating new docs finishes
|
||||
if (newerDocsExist && path === loadedPath) {
|
||||
return null;
|
||||
}
|
||||
return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path);
|
||||
}, [techdocsStorageApi, kind, namespace, name, path]);
|
||||
}, [techdocsStorageApi, kind, namespace, name, path, isSynced]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSidebarPosition = () => {
|
||||
@@ -75,15 +111,36 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
};
|
||||
}, [shadowDomRef, shadowRoot, sidebars]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shadowRoot || loading || error) {
|
||||
return; // Shadow DOM isn't ready / It's not ready / Docs was not found
|
||||
useEffect(() => {
|
||||
if (rawPage) {
|
||||
setLoadedPath(path);
|
||||
}
|
||||
}, [rawPage, path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atInitialLoad === false) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
setAtInitialLoad(false);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!atInitialLoad && !!rawPage && syncInProgress) {
|
||||
setNewerDocsExist(true);
|
||||
}
|
||||
}, [atInitialLoad, rawPage, syncInProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
// Pre-render
|
||||
const transformedElement = transformer(value as string, [
|
||||
const transformedElement = transformer(rawPage as string, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
@@ -145,6 +202,9 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
return; // An unexpected error occurred
|
||||
}
|
||||
|
||||
const shadowDiv: HTMLElement = shadowDomRef.current!;
|
||||
const shadowRoot =
|
||||
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
|
||||
Array.from(shadowRoot.children).forEach(child =>
|
||||
shadowRoot.removeChild(child),
|
||||
);
|
||||
@@ -166,17 +226,15 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
window.scroll({ top: 0 });
|
||||
const parsedUrl = new URL(url);
|
||||
if (newerDocsExist && isSynced) {
|
||||
// link navigation will load newer docs
|
||||
setNewerDocsExist(false);
|
||||
}
|
||||
if (parsedUrl.hash) {
|
||||
history.pushState(
|
||||
null,
|
||||
'',
|
||||
`${parsedUrl.pathname}${parsedUrl.hash}`,
|
||||
);
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
}
|
||||
|
||||
shadowRoot?.querySelector(parsedUrl.hash)?.scrollIntoView();
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
@@ -204,30 +262,49 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
}),
|
||||
]);
|
||||
}, [
|
||||
name,
|
||||
path,
|
||||
shadowRoot,
|
||||
value,
|
||||
error,
|
||||
loading,
|
||||
namespace,
|
||||
kind,
|
||||
rawPage,
|
||||
entityId,
|
||||
navigate,
|
||||
onReady,
|
||||
shadowDomRef,
|
||||
path,
|
||||
techdocsStorageApi,
|
||||
theme,
|
||||
onReady,
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
newerDocsExist,
|
||||
isSynced,
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
// TODO Enhance API call to return customize error objects so we can identify which we ran into
|
||||
// For now this defaults to display error code 404
|
||||
return <TechDocsNotFound statusCode={404} errorMessage={error.message} />;
|
||||
// docLoadError not considered an error state if sync request is still ongoing
|
||||
// or sync just completed and doc is loading again
|
||||
if ((docLoadError && !syncInProgress && !docLoading) || syncError) {
|
||||
let errMessage = '';
|
||||
if (docLoadError) {
|
||||
errMessage += ` Load error: ${docLoadError}`;
|
||||
}
|
||||
if (syncError) errMessage += ` Build error: ${syncError}`;
|
||||
return <TechDocsNotFound errorMessage={errMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? <TechDocsProgressBar /> : null}
|
||||
{newerDocsExist && !isSynced ? (
|
||||
<Alert variant="outlined" severity="info">
|
||||
A newer version of this documentation is being prepared and will be
|
||||
available shortly.
|
||||
</Alert>
|
||||
) : null}
|
||||
{newerDocsExist && isSynced ? (
|
||||
<Alert variant="outlined" severity="success">
|
||||
A newer version of this documentation is now available, please refresh
|
||||
to view.
|
||||
</Alert>
|
||||
) : null}
|
||||
{docLoading || (docLoadError && syncInProgress) ? (
|
||||
<TechDocsProgressBar />
|
||||
) : null}
|
||||
<div ref={shadowDomRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,17 +43,14 @@ describe('<TechDocsNotFound errorMessage="This is a custom error message" />', (
|
||||
});
|
||||
|
||||
describe('<TechDocsNotFound statusCode={500} errorMessage="This is a custom error message" />', () => {
|
||||
it('should render with a custom status code, custom error message and go back link', () => {
|
||||
it('should render with a 404 code, custom error message and go back link', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<TechDocsNotFound
|
||||
statusCode={500}
|
||||
errorMessage="This is a custom error message"
|
||||
/>,
|
||||
<TechDocsNotFound errorMessage="This is a custom error message" />,
|
||||
),
|
||||
);
|
||||
rendered.getByText(/This is a custom error message/i);
|
||||
rendered.getByText(/500/i);
|
||||
rendered.getByText(/404/i);
|
||||
rendered.getByText(/Looks like someone dropped the mic!/i);
|
||||
expect(rendered.getByTestId('go-back-link')).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -19,10 +19,9 @@ import { ErrorPage, useApi, configApiRef } from '@backstage/core';
|
||||
|
||||
type Props = {
|
||||
errorMessage?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => {
|
||||
export const TechDocsNotFound = ({ errorMessage }: Props) => {
|
||||
const techdocsBuilder = useApi(configApiRef).getOptionalString(
|
||||
'techdocs.builder',
|
||||
);
|
||||
@@ -38,7 +37,7 @@ export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => {
|
||||
|
||||
return (
|
||||
<ErrorPage
|
||||
status={statusCode ? statusCode.toString() : '404'}
|
||||
status="404"
|
||||
statusMessage={errorMessage || 'Documentation not found'}
|
||||
additionalInfo={additionalInfo}
|
||||
/>
|
||||
|
||||
@@ -1,17 +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.
|
||||
*/
|
||||
|
||||
export { useShadowDom } from './shadowDom';
|
||||
@@ -1,44 +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 { renderWithEffects } from '@backstage/test-utils';
|
||||
import { useShadowDom } from './shadowDom';
|
||||
|
||||
const ComponentWithoutHook = () => {
|
||||
return <div data-testid="shadow-dom" />;
|
||||
};
|
||||
|
||||
const ComponentWithHook = () => {
|
||||
const [ref] = useShadowDom();
|
||||
return <div data-testid="shadow-dom" ref={ref} />;
|
||||
};
|
||||
|
||||
describe('useShadowDom', () => {
|
||||
it('does not create a Shadow DOM instance', async () => {
|
||||
const rendered = await renderWithEffects(<ComponentWithoutHook />);
|
||||
|
||||
const divElement = rendered.getByTestId('shadow-dom');
|
||||
expect(divElement.shadowRoot).not.toBeInstanceOf(ShadowRoot);
|
||||
});
|
||||
|
||||
it('create a Shadow DOM instance', async () => {
|
||||
const rendered = await renderWithEffects(<ComponentWithHook />);
|
||||
|
||||
const divElement = rendered.getByTestId('shadow-dom');
|
||||
expect(divElement.shadowRoot).toBeInstanceOf(ShadowRoot);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +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 { useEffect, useRef } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
type IUseShadowDOM = () => [RefObject<HTMLDivElement>, ShadowRoot?];
|
||||
|
||||
export const useShadowDom: IUseShadowDOM = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const divElement = ref.current;
|
||||
divElement?.attachShadow({ mode: 'open' });
|
||||
}, []);
|
||||
|
||||
return [ref, ref.current?.shadowRoot || undefined];
|
||||
};
|
||||
@@ -14,5 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './hooks';
|
||||
export * from './components';
|
||||
|
||||
@@ -23,6 +23,7 @@ const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
|
||||
const techdocsStorageApi: TechDocsStorage = {
|
||||
getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)),
|
||||
getEntityDocs: () => new Promise(resolve => resolve('yes!')),
|
||||
syncEntityDocs: () => new Promise(resolve => resolve(true)),
|
||||
};
|
||||
|
||||
const fixture = `
|
||||
|
||||
Reference in New Issue
Block a user