TechDocs: show outdated docs and asnyc build new

Signed-off-by: Chongyang Adrian, Ke <ftt.adrian.ke@grabtaxi.com>
This commit is contained in:
Chongyang Adrian, Ke
2021-03-16 14:28:59 +08:00
parent 68a10d34f9
commit aaeb7ecf3c
15 changed files with 327 additions and 204 deletions
+8
View File
@@ -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}.`,
);
}
}
+67 -59
View File
@@ -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.
+78 -6
View File
@@ -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,
+3 -1
View File
@@ -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"
+2 -2
View File
@@ -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/`,
);
});
});
+57 -5
View File
@@ -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();
}
}
+105 -28
View File
@@ -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];
};
-1
View File
@@ -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 = `