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:
Dominik Henneke
2021-06-09 10:49:10 +02:00
parent fea7fa0ba6
commit 1dfec7a2ae
10 changed files with 1053 additions and 264 deletions
@@ -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.
-149
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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",
+3 -1
View File
@@ -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,
+9 -5
View File
@@ -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(),