Refactor the implicit logic from <Reader /> into an explicit state machine
Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Refactor the implicit logic from `<Reader />` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend.
|
||||
@@ -1,149 +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 { EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { TechDocsStorageApi } from '../src/api';
|
||||
|
||||
export class TechDocsDevStorageApi implements TechDocsStorageApi {
|
||||
public configApi: Config;
|
||||
public discoveryApi: DiscoveryApi;
|
||||
public identityApi: IdentityApi;
|
||||
|
||||
constructor({
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
}: {
|
||||
configApi: Config;
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
}) {
|
||||
this.configApi = configApi;
|
||||
this.discoveryApi = discoveryApi;
|
||||
this.identityApi = identityApi;
|
||||
}
|
||||
|
||||
async getApiOrigin() {
|
||||
return (
|
||||
this.configApi.getOptionalString('techdocs.requestUrl') ??
|
||||
(await this.discoveryApi.getBaseUrl('techdocs'))
|
||||
);
|
||||
}
|
||||
|
||||
async getStorageUrl() {
|
||||
return (
|
||||
this.configApi.getOptionalString('techdocs.storageUrl') ??
|
||||
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
`${url.endsWith('/') ? url : `${url}/`}index.html`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const { name } = entityId;
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString();
|
||||
}
|
||||
}
|
||||
+182
-11
@@ -14,11 +14,105 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core';
|
||||
import {
|
||||
configApiRef,
|
||||
discoveryApiRef,
|
||||
Header,
|
||||
identityApiRef,
|
||||
Page,
|
||||
TabbedLayout,
|
||||
} from '@backstage/core';
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { techdocsPlugin } from '../src/plugin';
|
||||
import { TechDocsDevStorageApi } from './api';
|
||||
import { techdocsStorageApiRef } from '../src';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import React from 'react';
|
||||
import {
|
||||
Reader,
|
||||
SyncResult,
|
||||
TechDocsStorageApi,
|
||||
techdocsStorageApiRef,
|
||||
} from '../src';
|
||||
|
||||
// used so each route can provide it's own implementation in the constructor of the react component
|
||||
let apiHolder: TechDocsStorageApi | undefined = undefined;
|
||||
|
||||
const apiBridge: TechDocsStorageApi = {
|
||||
getApiOrigin: async () => '',
|
||||
getBaseUrl: (...args) => apiHolder!.getBaseUrl(...args),
|
||||
getBuilder: () => apiHolder!.getBuilder(),
|
||||
getStorageUrl: () => apiHolder!.getStorageUrl(),
|
||||
getEntityDocs: (...args) => apiHolder!.getEntityDocs(...args),
|
||||
syncEntityDocs: (...args) => apiHolder!.syncEntityDocs(...args),
|
||||
};
|
||||
|
||||
const mockContent = `
|
||||
<h1>Hello World!</h1>
|
||||
<p>This is an example content that will actually be provided by a MkDocs powered site</p>
|
||||
`;
|
||||
|
||||
function createPage({
|
||||
entityDocs,
|
||||
syncDocs,
|
||||
syncDocsDelay,
|
||||
}: {
|
||||
entityDocs?: (props: {
|
||||
called: number;
|
||||
content: string;
|
||||
}) => string | Promise<string>;
|
||||
syncDocs: () => SyncResult;
|
||||
syncDocsDelay?: number;
|
||||
}) {
|
||||
class Api implements TechDocsStorageApi {
|
||||
private entityDocsCallCount: number = 0;
|
||||
|
||||
getApiOrigin = async () => '';
|
||||
getBaseUrl = async () => '';
|
||||
getBuilder = async () => 'local';
|
||||
getStorageUrl = async () => '';
|
||||
|
||||
async getEntityDocs() {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (!entityDocs) {
|
||||
return mockContent;
|
||||
}
|
||||
|
||||
return entityDocs({
|
||||
called: this.entityDocsCallCount++,
|
||||
content: mockContent,
|
||||
});
|
||||
}
|
||||
|
||||
async syncEntityDocs() {
|
||||
if (syncDocsDelay) {
|
||||
await new Promise(resolve => setTimeout(resolve, syncDocsDelay));
|
||||
}
|
||||
|
||||
return syncDocs();
|
||||
}
|
||||
}
|
||||
|
||||
class Component extends React.Component {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
apiHolder = new Api();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Reader
|
||||
entityId={{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'my-docs',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
createDevApp()
|
||||
.registerApi({
|
||||
@@ -28,12 +122,89 @@ createDevApp()
|
||||
discoveryApi: discoveryApiRef,
|
||||
identityApi: identityApiRef,
|
||||
},
|
||||
factory: ({ configApi, discoveryApi, identityApi }) =>
|
||||
new TechDocsDevStorageApi({
|
||||
configApi,
|
||||
discoveryApi,
|
||||
identityApi,
|
||||
}),
|
||||
factory: () => apiBridge,
|
||||
})
|
||||
|
||||
.addPage({
|
||||
title: 'TechDocs',
|
||||
element: (
|
||||
<Page themeId="home">
|
||||
<Header title="TechDocs" />
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/fresh" title="Fresh">
|
||||
{createPage({
|
||||
syncDocs: () => 'cached',
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/stale" title="Stale">
|
||||
{createPage({
|
||||
syncDocs: () => 'updated',
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/initial" title="Initial Build">
|
||||
{createPage({
|
||||
entityDocs: ({ called, content }) => {
|
||||
if (called < 1) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
syncDocs: () => 'updated',
|
||||
syncDocsDelay: 10000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/not-found" title="Not Found">
|
||||
{createPage({
|
||||
entityDocs: () => {
|
||||
throw new NotFoundError('Not found, some error message...');
|
||||
},
|
||||
syncDocs: () => 'cached',
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/error" title="Error">
|
||||
{createPage({
|
||||
entityDocs: () => {
|
||||
throw new Error('Another more critical error');
|
||||
},
|
||||
syncDocs: () => 'cached',
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/serror" title="Sync Error">
|
||||
{createPage({
|
||||
syncDocs: () => {
|
||||
throw new Error('Some random error');
|
||||
},
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/berror" title="Both Error">
|
||||
{createPage({
|
||||
entityDocs: () => {
|
||||
throw new Error('Some random error');
|
||||
},
|
||||
syncDocs: () => {
|
||||
throw new Error('Some random error');
|
||||
},
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
|
||||
<TabbedLayout.Route path="/timeout" title="Sync Timeout">
|
||||
{createPage({
|
||||
syncDocs: () => 'timeout',
|
||||
syncDocsDelay: 2000,
|
||||
})}
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
</Page>
|
||||
),
|
||||
})
|
||||
.registerPlugin(techdocsPlugin)
|
||||
.render();
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@backstage/test-utils": "^0.1.13",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@testing-library/user-event": "^13.1.8",
|
||||
"@types/react": "^16.9",
|
||||
"@types/jest": "^26.0.7",
|
||||
|
||||
@@ -28,12 +28,14 @@ export const techdocsApiRef = createApiRef<TechDocsApi>({
|
||||
description: 'Used to make requests towards techdocs API',
|
||||
});
|
||||
|
||||
export type SyncResult = 'cached' | 'updated' | 'timeout';
|
||||
|
||||
export interface TechDocsStorageApi {
|
||||
getApiOrigin(): Promise<string>;
|
||||
getStorageUrl(): Promise<string>;
|
||||
getBuilder(): Promise<string>;
|
||||
getEntityDocs(entityId: EntityName, path: string): Promise<string>;
|
||||
syncEntityDocs(entityId: EntityName): Promise<boolean>;
|
||||
syncEntityDocs(entityId: EntityName): Promise<SyncResult>;
|
||||
getBaseUrl(
|
||||
oldBaseUrl: string,
|
||||
entityId: EntityName,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { TechDocsApi, TechDocsStorageApi } from './api';
|
||||
import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
|
||||
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
|
||||
|
||||
/**
|
||||
@@ -195,7 +195,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
* @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): Promise<boolean> {
|
||||
async syncEntityDocs(entityId: EntityName): Promise<SyncResult> {
|
||||
const { kind, namespace, name } = entityId;
|
||||
|
||||
const apiOrigin = await this.getApiOrigin();
|
||||
@@ -215,16 +215,20 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
switch (request.status) {
|
||||
case 404:
|
||||
throw new NotFoundError((await request.json()).error);
|
||||
|
||||
case 200:
|
||||
case 201:
|
||||
case 304:
|
||||
return true;
|
||||
return 'cached';
|
||||
|
||||
case 201:
|
||||
return 'updated';
|
||||
|
||||
// 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;
|
||||
return 'timeout';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import { useApi } from '@backstage/core';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { useTheme } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
import {
|
||||
addBaseUrl,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from '../transformers';
|
||||
import { TechDocsNotFound } from './TechDocsNotFound';
|
||||
import TechDocsProgressBar from './TechDocsProgressBar';
|
||||
import { useRawPage } from './useRawPage';
|
||||
import { useReaderState } from './useReaderState';
|
||||
|
||||
type Props = {
|
||||
entityId: EntityName;
|
||||
@@ -49,61 +49,37 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
const { '*': path } = useParams();
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
|
||||
const { state, content: rawPage, errorMessage } = useReaderState(
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
path,
|
||||
);
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
const [sidebars, setSidebars] = useState<HTMLElement[]>();
|
||||
const navigate = useNavigate();
|
||||
const shadowDomRef = useRef<HTMLDivElement>(null);
|
||||
const [loadedPath, setLoadedPath] = useState('');
|
||||
const [atInitialLoad, setAtInitialLoad] = useState(true);
|
||||
const [newerDocsExist, setNewerDocsExist] = useState(false);
|
||||
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
|
||||
|
||||
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,
|
||||
const updateSidebarPosition = useCallback(() => {
|
||||
if (!!shadowDomRef.current && !!sidebars) {
|
||||
const mdTabs = shadowDomRef.current!.querySelector(
|
||||
'.md-container > .md-tabs',
|
||||
);
|
||||
sidebars!.forEach(sidebar => {
|
||||
const newTop = Math.max(
|
||||
shadowDomRef.current!.getBoundingClientRect().top,
|
||||
0,
|
||||
);
|
||||
sidebar.style.top = mdTabs
|
||||
? `${newTop + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${newTop}px`;
|
||||
});
|
||||
}
|
||||
return techdocsStorageApi.syncEntityDocs({ kind, namespace, name });
|
||||
}, [techdocsStorageApi, kind, namespace, name]);
|
||||
|
||||
const {
|
||||
value: rawPage,
|
||||
loading: docLoading,
|
||||
error: docLoadError,
|
||||
retry,
|
||||
} = useRawPage(path, kind, namespace, name);
|
||||
}, [shadowDomRef, sidebars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSynced && newerDocsExist && path !== loadedPath) {
|
||||
retry();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateSidebarPosition = () => {
|
||||
if (!!shadowDomRef.current && !!sidebars) {
|
||||
const mdTabs = shadowDomRef.current!.querySelector(
|
||||
'.md-container > .md-tabs',
|
||||
);
|
||||
sidebars!.forEach(sidebar => {
|
||||
const newTop = Math.max(
|
||||
shadowDomRef.current!.getBoundingClientRect().top,
|
||||
0,
|
||||
);
|
||||
sidebar.style.top = mdTabs
|
||||
? `${newTop + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${newTop}px`;
|
||||
});
|
||||
}
|
||||
};
|
||||
updateSidebarPosition();
|
||||
window.addEventListener('scroll', updateSidebarPosition);
|
||||
window.addEventListener('resize', updateSidebarPosition);
|
||||
@@ -111,28 +87,8 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
window.removeEventListener('scroll', updateSidebarPosition);
|
||||
window.removeEventListener('resize', updateSidebarPosition);
|
||||
};
|
||||
}, [shadowDomRef, sidebars]);
|
||||
|
||||
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]);
|
||||
// an update to "state" might lead to an updated UI so we include it as a trigger
|
||||
}, [updateSidebarPosition, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
@@ -142,12 +98,16 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
onReady();
|
||||
}
|
||||
// Pre-render
|
||||
const transformedElement = transformer(rawPage.content, [
|
||||
const transformedElement = transformer(rawPage, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: rawPage.entityId,
|
||||
path: rawPage.path,
|
||||
entityId: {
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
removeMkdocsHeader(),
|
||||
@@ -292,10 +252,6 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
if (newerDocsExist && isSynced) {
|
||||
// link navigation will load newer docs
|
||||
setNewerDocsExist(false);
|
||||
}
|
||||
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
@@ -337,6 +293,10 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
}),
|
||||
]);
|
||||
}, [
|
||||
path,
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
rawPage,
|
||||
navigate,
|
||||
onReady,
|
||||
@@ -347,39 +307,40 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
theme.palette.primary.main,
|
||||
theme.palette.background.paper,
|
||||
theme.palette.background.default,
|
||||
newerDocsExist,
|
||||
isSynced,
|
||||
scmIntegrationsApi,
|
||||
]);
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
{newerDocsExist && !isSynced ? (
|
||||
{(state === 'CHECKING' || state === 'INITIAL_BUILD') && (
|
||||
<TechDocsProgressBar />
|
||||
)}
|
||||
{state === 'CONTENT_STALE_REFRESHING' && (
|
||||
<Alert variant="outlined" severity="info">
|
||||
A newer version of this documentation is being prepared and will be
|
||||
available shortly.
|
||||
</Alert>
|
||||
) : null}
|
||||
{newerDocsExist && isSynced ? (
|
||||
)}
|
||||
{state === 'CONTENT_STALE_READY' && (
|
||||
<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}
|
||||
)}
|
||||
{state === 'CONTENT_STALE_TIMEOUT' && (
|
||||
<Alert variant="outlined" severity="warning">
|
||||
Building a newer version of this documentation took longer than
|
||||
expected. Please refresh to try again.
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_STALE_ERROR' && (
|
||||
<Alert variant="outlined" severity="error">
|
||||
Building a newer version of this documentation failed. {errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{state === 'CONTENT_NOT_FOUND' && (
|
||||
<TechDocsNotFound errorMessage={errorMessage} />
|
||||
)}
|
||||
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/*
|
||||
* 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 { NotFoundError } from '@backstage/errors';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
import {
|
||||
calculateDisplayState,
|
||||
reducer,
|
||||
useReaderState,
|
||||
} from './useReaderState';
|
||||
|
||||
describe('useReaderState', () => {
|
||||
let Wrapper: React.ComponentType;
|
||||
|
||||
const techdocsStorageApi: jest.Mocked<typeof techdocsStorageApiRef.T> = {
|
||||
getApiOrigin: jest.fn(),
|
||||
getBaseUrl: jest.fn(),
|
||||
getBuilder: jest.fn(),
|
||||
getEntityDocs: jest.fn(),
|
||||
getStorageUrl: jest.fn(),
|
||||
syncEntityDocs: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi);
|
||||
|
||||
Wrapper = ({ children }: { children?: React.ReactNode }) => (
|
||||
<ApiProvider apis={apis}>{children}</ApiProvider>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('calculateDisplayState', () => {
|
||||
it.each`
|
||||
contentLoading | content | activeSyncState | expected
|
||||
${true} | ${''} | ${''} | ${'CHECKING'}
|
||||
${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'}
|
||||
${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'}
|
||||
${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'}
|
||||
${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'}
|
||||
${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'}
|
||||
${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'}
|
||||
${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'}
|
||||
${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'}
|
||||
${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'}
|
||||
`(
|
||||
'should, when contentLoading=$contentLoading and content="$content" and activeSyncState=$activeSyncState, resolve to $expected',
|
||||
({ contentLoading, content, activeSyncState, expected }) => {
|
||||
expect(
|
||||
calculateDisplayState({
|
||||
contentLoading,
|
||||
content,
|
||||
activeSyncState,
|
||||
}),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('reducer', () => {
|
||||
const contentReloadFn = jest.fn();
|
||||
const oldState: Parameters<typeof reducer>[0] = {
|
||||
activeSyncState: 'CHECKING',
|
||||
contentIsStale: false,
|
||||
contentLoading: false,
|
||||
path: '',
|
||||
contentReload: contentReloadFn,
|
||||
};
|
||||
|
||||
it('should return a copy of the state', () => {
|
||||
expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({
|
||||
activeSyncState: 'CHECKING',
|
||||
contentIsStale: false,
|
||||
contentLoading: false,
|
||||
path: '/',
|
||||
contentReload: contentReloadFn,
|
||||
});
|
||||
|
||||
expect(oldState).toEqual({
|
||||
activeSyncState: 'CHECKING',
|
||||
contentIsStale: false,
|
||||
contentLoading: false,
|
||||
path: '',
|
||||
contentReload: contentReloadFn,
|
||||
});
|
||||
});
|
||||
|
||||
describe('"content" action', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
content: undefined,
|
||||
contentLoading: true,
|
||||
contentReload: undefined,
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
content: 'asdf',
|
||||
contentLoading: false,
|
||||
contentReload: contentReloadFn,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentLoading: false,
|
||||
content: 'asdf',
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should reset staleness', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentIsStale: true,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
content: 'asdf',
|
||||
contentLoading: false,
|
||||
contentReload: contentReloadFn,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
content: 'asdf',
|
||||
contentIsStale: false,
|
||||
activeSyncState: 'UP_TO_DATE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"navigate" action', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'navigate',
|
||||
path: '/',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should reset staleness', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentIsStale: true,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
},
|
||||
{
|
||||
type: 'navigate',
|
||||
path: '',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentIsStale: false,
|
||||
activeSyncState: 'UP_TO_DATE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"sync" action', () => {
|
||||
it('should update state', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'sync',
|
||||
state: 'BUILDING',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
activeSyncState: 'BUILDING',
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should set content to be stale but not reload', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentReload: undefined,
|
||||
},
|
||||
{
|
||||
type: 'sync',
|
||||
state: 'BUILD_READY',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
contentIsStale: true,
|
||||
contentReload: undefined,
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should not reload existing content', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
content: 'any content',
|
||||
},
|
||||
{
|
||||
type: 'sync',
|
||||
state: 'BUILD_READY',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
contentIsStale: true,
|
||||
content: 'any content',
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should trigger a reload', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'sync',
|
||||
state: 'BUILD_READY',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
contentIsStale: true,
|
||||
contentLoading: true,
|
||||
});
|
||||
|
||||
expect(contentReloadFn).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT reset staleness', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentIsStale: true,
|
||||
activeSyncState: 'BUILD_READY',
|
||||
},
|
||||
{
|
||||
type: 'sync',
|
||||
state: 'BUILDING',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentIsStale: true,
|
||||
activeSyncState: 'BUILDING',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook', () => {
|
||||
it('should handle up-to-date content', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
|
||||
return 'cached';
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
() => useReaderState('Component', 'default', 'backstage', '/example'),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle stale content', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
|
||||
techdocsStorageApi.syncEntityDocs.mockImplementation(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'updated';
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
() => useReaderState('Component', 'default', 'backstage', '/example'),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
// the content is returned but the sync is in progress
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
// the sync takes longer than 1 seconds so the refreshing state starts
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_REFRESHING',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
// the content is up-to-date
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_READY',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle timed-out refresh', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockResolvedValue('my content');
|
||||
techdocsStorageApi.syncEntityDocs.mockResolvedValue('timeout');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
() => useReaderState('Component', 'default', 'backstage', '/example'),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
// the content is returned but the sync is in progress
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_TIMEOUT',
|
||||
content: 'my content',
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle content error', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockRejectedValue(
|
||||
new NotFoundError('Some error description'),
|
||||
);
|
||||
techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange } = await renderHook(
|
||||
() => useReaderState('Component', 'default', 'backstage', '/example'),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
errorMessage: '',
|
||||
});
|
||||
|
||||
// the content loading threw an error
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_NOT_FOUND',
|
||||
content: undefined,
|
||||
errorMessage: ' Load error: NotFoundError: Some error description',
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* 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 { useApi } from '@backstage/core';
|
||||
import { useEffect, useMemo, useReducer } from 'react';
|
||||
import { useAsync, useAsyncRetry } from 'react-use';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
|
||||
/**
|
||||
* A state representation that is used to configure the UI of <Reader />
|
||||
*/
|
||||
type ContentStateTypes =
|
||||
/** There is nothing to display but a loading indicator */
|
||||
| 'CHECKING'
|
||||
|
||||
/** There is no content yet -> present a full screen loading page */
|
||||
| 'INITIAL_BUILD'
|
||||
|
||||
/** There is content, but the backend is about to update it */
|
||||
| 'CONTENT_STALE_REFRESHING'
|
||||
|
||||
/** There is content, but after a reload, the content will be different */
|
||||
| 'CONTENT_STALE_READY'
|
||||
|
||||
/** There is content, the backend tried to update it, but it took too long */
|
||||
| 'CONTENT_STALE_TIMEOUT'
|
||||
|
||||
/** There is content, the backend tried to update it, but failed */
|
||||
| 'CONTENT_STALE_ERROR'
|
||||
|
||||
/** There is nothing to see but a "not found" page. Is also shown on page load errors */
|
||||
| 'CONTENT_NOT_FOUND'
|
||||
|
||||
/** There is only the latest and greatest content */
|
||||
| 'CONTENT_FRESH';
|
||||
|
||||
/**
|
||||
* Calculate the state that should be reported to the display component.
|
||||
*/
|
||||
export function calculateDisplayState({
|
||||
contentLoading,
|
||||
content,
|
||||
activeSyncState,
|
||||
}: Pick<
|
||||
ReducerState,
|
||||
'contentLoading' | 'content' | 'activeSyncState'
|
||||
>): ContentStateTypes {
|
||||
// we have nothing to display yet
|
||||
if (contentLoading) {
|
||||
return 'CHECKING';
|
||||
}
|
||||
|
||||
// there is no content, but the sync process is still evaluating
|
||||
if (!content && activeSyncState === 'CHECKING') {
|
||||
return 'CHECKING';
|
||||
}
|
||||
|
||||
// there is no content yet so we assume that we are building it for the first time
|
||||
if (!content && activeSyncState === 'BUILDING') {
|
||||
return 'INITIAL_BUILD';
|
||||
}
|
||||
|
||||
// if there is still no content after building, it might just not exist
|
||||
if (!content) {
|
||||
return 'CONTENT_NOT_FOUND';
|
||||
}
|
||||
|
||||
// we are still building, but we already show stale content
|
||||
if (activeSyncState === 'BUILDING') {
|
||||
return 'CONTENT_STALE_REFRESHING';
|
||||
}
|
||||
|
||||
// the build is ready, but the content is still stale
|
||||
if (activeSyncState === 'BUILD_READY') {
|
||||
return 'CONTENT_STALE_READY';
|
||||
}
|
||||
|
||||
// the build timed out, but the content is still stale
|
||||
if (activeSyncState === 'BUILD_TIMED_OUT') {
|
||||
return 'CONTENT_STALE_TIMEOUT';
|
||||
}
|
||||
|
||||
// the build failed, but the content is still stale
|
||||
if (activeSyncState === 'ERROR') {
|
||||
return 'CONTENT_STALE_ERROR';
|
||||
}
|
||||
|
||||
// seems like the content is up-to-date (or we don't know yet and the sync process is still evaluating in the background)
|
||||
return 'CONTENT_FRESH';
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of the synchronization task. It checks whether the docs are
|
||||
* up-to-date. If they aren't, it triggers a build.
|
||||
*/
|
||||
type SyncStates =
|
||||
/** Checking if it should be synced */
|
||||
| 'CHECKING'
|
||||
|
||||
/** Building the documentation */
|
||||
| 'BUILDING'
|
||||
|
||||
/** Finished building the documentation */
|
||||
| 'BUILD_READY'
|
||||
|
||||
/** Building the documentation timed out */
|
||||
| 'BUILD_TIMED_OUT'
|
||||
|
||||
/** No need for a sync. The content was already up-to-date. */
|
||||
| 'UP_TO_DATE'
|
||||
|
||||
/** An error occurred */
|
||||
| 'ERROR';
|
||||
|
||||
type ReducerActions =
|
||||
| {
|
||||
type: 'sync';
|
||||
state: SyncStates;
|
||||
syncError?: Error;
|
||||
}
|
||||
| {
|
||||
type: 'content';
|
||||
content?: string;
|
||||
contentLoading: boolean;
|
||||
contentError?: Error;
|
||||
contentReload: () => void;
|
||||
}
|
||||
| { type: 'navigate'; path: string };
|
||||
|
||||
type ReducerState = {
|
||||
/**
|
||||
* The path of the current page
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* The current sync state
|
||||
*/
|
||||
activeSyncState: SyncStates;
|
||||
|
||||
/**
|
||||
* If true, the content is downloading from the storage.
|
||||
*/
|
||||
contentLoading: boolean;
|
||||
/**
|
||||
* The content that has been downloaded and should be displayed.
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* When called, the content is reloaded without refreshing the page.
|
||||
*/
|
||||
contentReload?: () => void;
|
||||
/**
|
||||
* If true, the content is considered stale and should be refreshed by the user via a refresh or a navigation.
|
||||
*/
|
||||
contentIsStale: boolean;
|
||||
|
||||
contentError?: Error;
|
||||
syncError?: Error;
|
||||
};
|
||||
|
||||
export function reducer(
|
||||
oldState: ReducerState,
|
||||
action: ReducerActions,
|
||||
): ReducerState {
|
||||
const newState = { ...oldState };
|
||||
|
||||
switch (action.type) {
|
||||
case 'sync':
|
||||
newState.activeSyncState = action.state;
|
||||
newState.syncError = action.syncError;
|
||||
|
||||
// whatever is stored as content, it can be considered as being stale
|
||||
if (newState.activeSyncState === 'BUILD_READY') {
|
||||
newState.contentIsStale = true;
|
||||
|
||||
// reload the content if this was the initial build OR the page was missing in the old version
|
||||
if (!newState.content && newState.contentReload) {
|
||||
newState.contentReload();
|
||||
|
||||
// eagerly mark the content to load to not get synchronization issues since
|
||||
// the async hook behind contentReload() doesn't update the reducer instantly
|
||||
// and might flash the "not found" page
|
||||
newState.contentLoading = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content':
|
||||
newState.content = action.content;
|
||||
newState.contentLoading = action.contentLoading;
|
||||
newState.contentReload = action.contentReload;
|
||||
newState.contentError = action.contentError;
|
||||
break;
|
||||
|
||||
case 'navigate':
|
||||
newState.path = action.path;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// a navigation or a content update removes the staleness and resets the sync state
|
||||
if (
|
||||
newState.contentIsStale &&
|
||||
['content', 'navigate'].includes(action.type)
|
||||
) {
|
||||
newState.contentIsStale = false;
|
||||
newState.activeSyncState = 'UP_TO_DATE';
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
export function useReaderState(
|
||||
kind: string,
|
||||
namespace: string,
|
||||
name: string,
|
||||
path: string,
|
||||
): { state: ContentStateTypes; content?: string; errorMessage?: string } {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
activeSyncState: 'CHECKING',
|
||||
path,
|
||||
contentLoading: true,
|
||||
contentIsStale: false,
|
||||
});
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
|
||||
// convert all path changes into actions
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'navigate', path });
|
||||
}, [path]);
|
||||
|
||||
// try to load the content
|
||||
const {
|
||||
value: content,
|
||||
loading: contentLoading,
|
||||
error: contentError,
|
||||
retry: contentReload,
|
||||
} = useAsyncRetry(
|
||||
async () =>
|
||||
techdocsStorageApi.getEntityDocs(
|
||||
{
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
path,
|
||||
),
|
||||
[techdocsStorageApi, kind, namespace, name, path],
|
||||
);
|
||||
|
||||
// convert all content changes into actions
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: 'content',
|
||||
content,
|
||||
contentLoading,
|
||||
contentReload,
|
||||
contentError,
|
||||
});
|
||||
}, [dispatch, content, contentLoading, contentReload, contentError]);
|
||||
|
||||
// try to derive the state. the function will fire events and we don't care for the return values
|
||||
useAsync(async () => {
|
||||
dispatch({ type: 'sync', state: 'CHECKING' });
|
||||
|
||||
// should only switch to BUILDING if the request takes more than 1 seconds
|
||||
const buildingTimeout = setTimeout(() => {
|
||||
dispatch({ type: 'sync', state: 'BUILDING' });
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const result = await techdocsStorageApi.syncEntityDocs({
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
});
|
||||
|
||||
if (result === 'updated') {
|
||||
dispatch({ type: 'sync', state: 'BUILD_READY' });
|
||||
} else if (result === 'cached') {
|
||||
dispatch({ type: 'sync', state: 'UP_TO_DATE' });
|
||||
} else {
|
||||
dispatch({ type: 'sync', state: 'BUILD_TIMED_OUT' });
|
||||
}
|
||||
} catch (e) {
|
||||
dispatch({ type: 'sync', state: 'ERROR', syncError: e });
|
||||
} finally {
|
||||
// Cancel the timer that sets the state "BUILDING"
|
||||
clearTimeout(buildingTimeout);
|
||||
}
|
||||
}, [kind, name, namespace, techdocsStorageApi, dispatch]);
|
||||
|
||||
const displayState = useMemo(
|
||||
() =>
|
||||
calculateDisplayState({
|
||||
activeSyncState: state.activeSyncState,
|
||||
contentLoading: state.contentLoading,
|
||||
content: state.content,
|
||||
}),
|
||||
[state.activeSyncState, state.content, state.contentLoading],
|
||||
);
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
let errMessage = '';
|
||||
if (state.contentError) {
|
||||
errMessage += ` Load error: ${state.contentError}`;
|
||||
}
|
||||
if (state.syncError) errMessage += ` Build error: ${state.syncError}`;
|
||||
|
||||
return errMessage;
|
||||
}, [state.syncError, state.contentError]);
|
||||
|
||||
return {
|
||||
state: displayState,
|
||||
content,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const techdocsStorageApi: TechDocsStorageApi = {
|
||||
Promise.resolve(new URL(o, DOC_STORAGE_URL).toString()),
|
||||
),
|
||||
getEntityDocs: () => new Promise(resolve => resolve('yes!')),
|
||||
syncEntityDocs: () => new Promise(resolve => resolve(true)),
|
||||
syncEntityDocs: () => new Promise(resolve => resolve('updated')),
|
||||
getApiOrigin: jest.fn(() => new Promise(resolve => resolve(API_ORIGIN_URL))),
|
||||
getBuilder: jest.fn(),
|
||||
getStorageUrl: jest.fn(),
|
||||
|
||||
Reference in New Issue
Block a user