TechDocs: show outdated docs and asnyc build new
Signed-off-by: Chongyang Adrian, Ke <ftt.adrian.ke@grabtaxi.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': minor
|
||||
'@backstage/plugin-techdocs-backend': minor
|
||||
---
|
||||
|
||||
When newer documentation available but not built, show older documentation while async building newer
|
||||
TechDocs backend: /sync endpoint added to support above, returns immediate success if docs don't need a build, returns delayed success after build if needed
|
||||
TechDocs backend: /docs endpoint removed as frontend can directly request to techdocs.storageUrl or /static/docs
|
||||
@@ -101,7 +101,7 @@ export class DocsBuilder {
|
||||
} catch (err) {
|
||||
// Proceed with a fresh build
|
||||
this.logger.warn(
|
||||
`Unable to read techdocs_metadata.json, error ${err}.`,
|
||||
`Unable to read techdocs_metadata.json, proceeding with fresh build, error ${err}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Logger } from 'winston';
|
||||
import { DocsBuilder } from '../DocsBuilder';
|
||||
import { shouldCheckForUpdate } from '../DocsBuilder/BuildMetadataStorage';
|
||||
import { getEntityNameFromUrlPath } from './helpers';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
type RouterOptions = {
|
||||
preparers: PreparerBuilder;
|
||||
@@ -105,12 +106,11 @@ export async function createRouter({
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/docs/:namespace/:kind/:name/*', async (req, res) => {
|
||||
// Check if docs are the latest version and trigger rebuilds if not
|
||||
// Responds with immediate success if rebuild not needed
|
||||
// If a build is required, responds with a success when finished
|
||||
router.get('/sync/:namespace/:kind/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const storageUrl =
|
||||
config.getOptionalString('techdocs.storageUrl') ??
|
||||
`${await discovery.getExternalBaseUrl('techdocs')}/static/docs`;
|
||||
|
||||
const catalogUrl = await discovery.getBaseUrl('catalog');
|
||||
const triple = [kind, namespace, name].map(encodeURIComponent).join('/');
|
||||
|
||||
@@ -127,6 +127,16 @@ export async function createRouter({
|
||||
|
||||
const entity: Entity = await catalogRes.json();
|
||||
|
||||
if (!entity.metadata.uid) {
|
||||
throw new NotFoundError('Entity metadata UID missing');
|
||||
}
|
||||
if (!shouldCheckForUpdate(entity.metadata.uid)) {
|
||||
res.status(200).json({
|
||||
message: `Last check for documentation update is recent, did not retry.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let publisherType = '';
|
||||
try {
|
||||
publisherType = config.getString('techdocs.publisher.type');
|
||||
@@ -137,63 +147,61 @@ export async function createRouter({
|
||||
'https://backstage.io/docs/features/techdocs/architecture',
|
||||
);
|
||||
}
|
||||
|
||||
// techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to 'local'
|
||||
// If set to 'external', it will only try to fetch and assume that an external process (e.g. CI/CD pipeline
|
||||
// of the repository) is responsible for building and publishing documentation to the storage provider.
|
||||
if (
|
||||
config.getString('techdocs.builder') === 'local' &&
|
||||
entity.metadata.uid &&
|
||||
shouldCheckForUpdate(entity.metadata.uid)
|
||||
) {
|
||||
const docsBuilder = new DocsBuilder({
|
||||
preparers,
|
||||
generators,
|
||||
publisher,
|
||||
dockerClient,
|
||||
logger,
|
||||
entity,
|
||||
// If set to 'external', it will assume that an external process (e.g. CI/CD pipeline
|
||||
// of the repository) is responsible for building and publishing documentation to the storage provider
|
||||
if (config.getString('techdocs.builder') !== 'local') {
|
||||
res.status(200).json({
|
||||
message:
|
||||
'`techdocs.builder` app config is not set to `local`, so docs will not be generated locally and sync is not required.',
|
||||
});
|
||||
let foundDocs = false;
|
||||
switch (publisherType) {
|
||||
case 'local':
|
||||
case 'awsS3':
|
||||
case 'azureBlobStorage':
|
||||
case 'openStackSwift':
|
||||
case 'googleGcs':
|
||||
// This block should be valid for all storage implementations. So no need to duplicate in future,
|
||||
// add the publisher type in the list here.
|
||||
await docsBuilder.build();
|
||||
// With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched
|
||||
// on the user's page. If not, respond with a message asking them to check back later.
|
||||
// The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
if (await publisher.hasDocsBeenGenerated(entity)) {
|
||||
foundDocs = true;
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (!foundDocs) {
|
||||
logger.error(
|
||||
'Published files are taking longer to show up in storage. Something went wrong.',
|
||||
);
|
||||
res.status(408).json({
|
||||
error:
|
||||
'Sorry! It took too long for the generated docs to show up in storage. Check back later.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
res.status(400).json({
|
||||
error: `Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const docsBuilder = new DocsBuilder({
|
||||
preparers,
|
||||
generators,
|
||||
publisher,
|
||||
dockerClient,
|
||||
logger,
|
||||
entity,
|
||||
});
|
||||
let foundDocs = false;
|
||||
switch (publisherType) {
|
||||
case 'local':
|
||||
case 'awsS3':
|
||||
case 'azureBlobStorage':
|
||||
case 'openStackSwift':
|
||||
case 'googleGcs':
|
||||
// This block should be valid for all storage implementations. So no need to duplicate in future,
|
||||
// add the publisher type in the list here.
|
||||
await docsBuilder.build();
|
||||
// With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched
|
||||
// on the user's page. If not, respond with a message asking them to check back later.
|
||||
// The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
if (await publisher.hasDocsBeenGenerated(entity)) {
|
||||
foundDocs = true;
|
||||
break;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
if (!foundDocs) {
|
||||
logger.error(
|
||||
'Published files are taking longer to show up in storage. Something went wrong.',
|
||||
);
|
||||
throw new NotFoundError(
|
||||
'Sorry! It took too long for the generated docs to show up in storage. Check back later.',
|
||||
);
|
||||
}
|
||||
res
|
||||
.status(201)
|
||||
.json({ message: 'Docs updated or did not need updating' });
|
||||
break;
|
||||
default:
|
||||
throw new NotFoundError(
|
||||
`Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`,
|
||||
);
|
||||
}
|
||||
|
||||
res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`);
|
||||
});
|
||||
|
||||
// Route middleware which serves files from the storage set in the publisher.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core';
|
||||
import { Config } from '@backstage/config';
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { TechDocsStorage } from '../src/api';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
|
||||
export class TechDocsDevStorageApi implements TechDocsStorage {
|
||||
public configApi: Config;
|
||||
@@ -44,11 +45,29 @@ export class TechDocsDevStorageApi implements TechDocsStorage {
|
||||
);
|
||||
}
|
||||
|
||||
async getEntityDocs(entityId: EntityName, path: string) {
|
||||
const { name } = entityId;
|
||||
async getStorageUrl() {
|
||||
return (
|
||||
this.configApi.getOptionalString('techdocs.storageUrl') ??
|
||||
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
|
||||
);
|
||||
}
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
const url = `${apiOrigin}/${name}/${path}`;
|
||||
async getBuilder() {
|
||||
return this.configApi.getString('techdocs.builder');
|
||||
}
|
||||
|
||||
async fetchUrl(url: string) {
|
||||
const token = await this.identityApi.getIdToken();
|
||||
return fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
}
|
||||
|
||||
async getEntityDocs(entityId: EntityName, path: string) {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const storageUrl = await this.getStorageUrl();
|
||||
const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
|
||||
const token = await this.identityApi.getIdToken();
|
||||
|
||||
const request = await fetch(
|
||||
@@ -58,13 +77,66 @@ export class TechDocsDevStorageApi implements TechDocsStorage {
|
||||
},
|
||||
);
|
||||
|
||||
if (request.status === 404) {
|
||||
throw new Error('Page not found');
|
||||
let errorMessage = '';
|
||||
switch (request.status) {
|
||||
case 404:
|
||||
errorMessage = 'Page not found. ';
|
||||
// path is empty for the home page of an entity's docs site
|
||||
if (!path) {
|
||||
errorMessage +=
|
||||
'This could be because there is no index.md file in the root of the docs directory of this repository.';
|
||||
}
|
||||
throw new NotFoundError(errorMessage);
|
||||
case 500:
|
||||
errorMessage =
|
||||
'Could not generate documentation or an error in the TechDocs backend. ';
|
||||
throw new Error(errorMessage);
|
||||
default:
|
||||
// Do nothing
|
||||
break;
|
||||
}
|
||||
|
||||
return request.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if docs are the latest version and trigger rebuilds 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
|
||||
*/
|
||||
async syncEntityDocs(entityId: EntityName) {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
|
||||
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 this.fetchUrl(
|
||||
`${url.endsWith('/') ? url : `${url}/`}index.html`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (request.status) {
|
||||
case 404:
|
||||
throw (await request.json()).error;
|
||||
case 200:
|
||||
case 201:
|
||||
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,
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
"@backstage/test-utils": "^0.1.9",
|
||||
"@backstage/theme": "^0.2.4",
|
||||
"@backstage/techdocs-common": "^0.4.5",
|
||||
"@backstage/errors": "^0.1.1",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
@@ -56,8 +56,10 @@
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/react": "^16.9",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^14.14.32",
|
||||
"@types/react": "^16.9",
|
||||
"canvas": "^2.6.1",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"msw": "^0.21.2"
|
||||
|
||||
@@ -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