Merge branch 'master' into feature/techdocs-e2e
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { Reader } from './reader';
|
||||
export const EntityPageDocs = ({ entity }: { entity: Entity }) => {
|
||||
return (
|
||||
<Reader
|
||||
withSearch={false}
|
||||
entityId={{
|
||||
kind: entity.kind,
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
|
||||
@@ -18,23 +18,24 @@ import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
rootRouteRef,
|
||||
rootDocsRouteRef,
|
||||
rootCatalogDocsRouteRef,
|
||||
} from './routes';
|
||||
import { TechDocsHome } from './home/components/TechDocsHome';
|
||||
import { TechDocsPage } from './reader/components/TechDocsPage';
|
||||
import { TechDocsIndexPage } from './home/components/TechDocsIndexPage';
|
||||
import { TechDocsPage as TechDocsReaderPage } from './reader/components/TechDocsPage';
|
||||
import { EntityPageDocs } from './EntityPageDocs';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core-components';
|
||||
|
||||
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
|
||||
export const isTechDocsAvailable = (entity: Entity) =>
|
||||
Boolean(entity?.metadata?.annotations?.[TECHDOCS_ANNOTATION]);
|
||||
|
||||
export const Router = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={`/${rootRouteRef.path}`} element={<TechDocsHome />} />
|
||||
<Route path={`/${rootDocsRouteRef.path}`} element={<TechDocsPage />} />
|
||||
<Route path="/" element={<TechDocsIndexPage />} />
|
||||
<Route
|
||||
path="/:namespace/:kind/:name/*"
|
||||
element={<TechDocsReaderPage />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
@@ -55,10 +56,7 @@ export const EmbeddedDocsRouter = (_props: Props) => {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={`/${rootCatalogDocsRouteRef.path}`}
|
||||
element={<EntityPageDocs entity={entity} />}
|
||||
/>
|
||||
<Route path="/*" element={<EntityPageDocs entity={entity} />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ import { Config } from '@backstage/config';
|
||||
import { UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import EventSource from 'eventsource';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { TechDocsStorageClient } from './client';
|
||||
|
||||
const MockedEventSource: jest.MockedClass<
|
||||
typeof EventSource
|
||||
> = EventSource as any;
|
||||
const MockedEventSource = EventSourcePolyfill as jest.MockedClass<
|
||||
typeof EventSourcePolyfill
|
||||
>;
|
||||
|
||||
jest.mock('eventsource');
|
||||
jest.mock('event-source-polyfill');
|
||||
|
||||
const mockEntity = {
|
||||
kind: 'Component',
|
||||
@@ -83,7 +83,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
if (type === 'finish' && typeof fn === 'function') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
@@ -91,9 +91,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
await storageApi.syncEntityDocs(mockEntity);
|
||||
|
||||
expect(
|
||||
MockedEventSource,
|
||||
).toBeCalledWith(
|
||||
expect(MockedEventSource).toBeCalledWith(
|
||||
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
|
||||
{ withCredentials: true, headers: {} },
|
||||
);
|
||||
@@ -109,7 +107,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
if (type === 'finish' && typeof fn === 'function') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
@@ -119,9 +117,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
await storageApi.syncEntityDocs(mockEntity);
|
||||
|
||||
expect(
|
||||
MockedEventSource,
|
||||
).toBeCalledWith(
|
||||
expect(MockedEventSource).toBeCalledWith(
|
||||
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
|
||||
{ withCredentials: true, headers: { Authorization: 'Bearer token' } },
|
||||
);
|
||||
@@ -137,7 +133,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
if (type === 'finish' && typeof fn === 'function') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
@@ -158,7 +154,7 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'finish') {
|
||||
if (type === 'finish' && typeof fn === 'function') {
|
||||
fn({ data: '{"updated": true}' } as any);
|
||||
}
|
||||
},
|
||||
@@ -179,11 +175,11 @@ describe('TechDocsStorageClient', () => {
|
||||
|
||||
MockedEventSource.prototype.addEventListener.mockImplementation(
|
||||
(type, fn) => {
|
||||
if (type === 'log') {
|
||||
if (type === 'log' && typeof fn === 'function') {
|
||||
fn({ data: '"A log message"' } as any);
|
||||
}
|
||||
|
||||
if (type === 'finish') {
|
||||
if (type === 'finish' && typeof fn === 'function') {
|
||||
fn({ data: '{"updated": false}' } as any);
|
||||
}
|
||||
},
|
||||
@@ -215,7 +211,7 @@ describe('TechDocsStorageClient', () => {
|
||||
const instance = MockedEventSource.mock
|
||||
.instances[0] as jest.Mocked<EventSource>;
|
||||
|
||||
instance.onerror({
|
||||
instance.onerror?.({
|
||||
status: 404,
|
||||
message: 'Some not found warning',
|
||||
} as any);
|
||||
@@ -241,7 +237,7 @@ describe('TechDocsStorageClient', () => {
|
||||
const instance = MockedEventSource.mock
|
||||
.instances[0] as jest.Mocked<EventSource>;
|
||||
|
||||
instance.onerror({
|
||||
instance.onerror?.({
|
||||
type: 'error',
|
||||
data: 'Some other error',
|
||||
} as any);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { NotFoundError, ResponseError } from '@backstage/errors';
|
||||
import EventSource from 'eventsource';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
|
||||
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
|
||||
|
||||
@@ -214,7 +214,8 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
|
||||
const token = await this.identityApi.getIdToken();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const source = new EventSource(url, {
|
||||
// Polyfill is used to add support for custom headers and auth
|
||||
const source = new EventSourcePolyfill(url, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
import { DocsResultListItem } from './DocsResultListItem';
|
||||
|
||||
// Using canvas to render text..
|
||||
jest.mock('react-text-truncate', () => {
|
||||
return ({ text }: { text: string }) => <span>{text}</span>;
|
||||
});
|
||||
|
||||
const validResult = {
|
||||
location: 'https://backstage.io/docs',
|
||||
title: 'Documentation',
|
||||
text: 'Backstage is an open-source developer portal that puts the developer experience first.',
|
||||
kind: 'library',
|
||||
namespace: '',
|
||||
name: 'Backstage',
|
||||
lifecycle: 'production',
|
||||
};
|
||||
|
||||
describe('DocsResultListItem test', () => {
|
||||
it('should render search doc passed in', async () => {
|
||||
const { findByText } = render(<DocsResultListItem result={validResult} />);
|
||||
|
||||
expect(
|
||||
await findByText('Documentation | Backstage docs'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText(
|
||||
'Backstage is an open-source developer portal that puts the developer experience first.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use title if defined', async () => {
|
||||
const { findByText } = render(
|
||||
<DocsResultListItem result={validResult} title="Count Dookumentation" />,
|
||||
);
|
||||
|
||||
expect(await findByText('Count Dookumentation')).toBeInTheDocument();
|
||||
expect(
|
||||
await findByText(
|
||||
'Backstage is an open-source developer portal that puts the developer experience first.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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, { PropsWithChildren } from 'react';
|
||||
import { Divider, ListItem, ListItemText, makeStyles } from '@material-ui/core';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
flexContainer: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
itemText: {
|
||||
width: '100%',
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
});
|
||||
|
||||
export const DocsResultListItem = ({
|
||||
result,
|
||||
lineClamp = 5,
|
||||
asListItem = true,
|
||||
asLink = true,
|
||||
title,
|
||||
}: {
|
||||
result: any;
|
||||
lineClamp?: number;
|
||||
asListItem?: boolean;
|
||||
asLink?: boolean;
|
||||
title?: string;
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const TextItem = () => (
|
||||
<ListItemText
|
||||
className={classes.itemText}
|
||||
primaryTypographyProps={{ variant: 'h6' }}
|
||||
primary={title ? title : `${result.title} | ${result.name} docs`}
|
||||
secondary={
|
||||
<TextTruncate
|
||||
line={lineClamp}
|
||||
truncateText="…"
|
||||
text={result.text}
|
||||
element="span"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const LinkWrapper = ({ children }: PropsWithChildren<{}>) =>
|
||||
asLink ? <Link to={result.location}>{children}</Link> : <>{children}</>;
|
||||
|
||||
const ListItemWrapper = ({ children }: PropsWithChildren<{}>) =>
|
||||
asListItem ? (
|
||||
<>
|
||||
<ListItem alignItems="flex-start" className={classes.flexContainer}>
|
||||
{children}
|
||||
</ListItem>
|
||||
<Divider component="li" />
|
||||
</>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkWrapper>
|
||||
<ListItemWrapper>
|
||||
<TextItem />
|
||||
</ListItemWrapper>
|
||||
</LinkWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { DocsResultListItem } from './DocsResultListItem';
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
declare module 'event-source-polyfill' {
|
||||
export class EventSourcePolyfill extends EventSource {
|
||||
constructor(
|
||||
url: string,
|
||||
options?: {
|
||||
withCredentials?: boolean;
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { DefaultTechDocsHome } from './DefaultTechDocsHome';
|
||||
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
ConfigReader,
|
||||
} from '@backstage/core-app-api';
|
||||
import {
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
storageApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
return {
|
||||
...actual,
|
||||
useOwnUser: () => 'test-user',
|
||||
};
|
||||
});
|
||||
|
||||
const mockCatalogApi = {
|
||||
getEntityByName: () => Promise.resolve(),
|
||||
getEntities: async () => ({
|
||||
items: [
|
||||
{
|
||||
apiVersion: 'version',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'owned',
|
||||
namespace: 'default',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as Partial<CatalogApi>;
|
||||
|
||||
describe('TechDocs Home', () => {
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
organization: {
|
||||
name: 'My Company',
|
||||
},
|
||||
});
|
||||
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[catalogApiRef, mockCatalogApi],
|
||||
[configApiRef, configApi],
|
||||
[storageApiRef, MockStorageApi.create()],
|
||||
]);
|
||||
|
||||
it('should render a TechDocs home page', async () => {
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<DefaultTechDocsHome />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
expect(await screen.findByText('Documentation')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/Documentation available in My Company/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
Content,
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import {
|
||||
EntityListContainer,
|
||||
FilterContainer,
|
||||
FilteredEntityLayout,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import {
|
||||
EntityListProvider,
|
||||
EntityOwnerPicker,
|
||||
EntityTagPicker,
|
||||
UserListFilterKind,
|
||||
UserListPicker,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { EntityListDocsTable } from './EntityListDocsTable';
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
import { TechDocsPicker } from './TechDocsPicker';
|
||||
import { DocsTableRow } from './types';
|
||||
|
||||
export const DefaultTechDocsHome = ({
|
||||
initialFilter = 'all',
|
||||
columns,
|
||||
actions,
|
||||
}: {
|
||||
initialFilter?: UserListFilterKind;
|
||||
columns?: TableColumn<DocsTableRow>[];
|
||||
actions?: TableProps<DocsTableRow>['actions'];
|
||||
}) => {
|
||||
return (
|
||||
<TechDocsPageWrapper>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>
|
||||
Discover documentation in your ecosystem.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<EntityListProvider>
|
||||
<FilteredEntityLayout>
|
||||
<FilterContainer>
|
||||
<TechDocsPicker />
|
||||
<UserListPicker initialFilter={initialFilter} />
|
||||
<EntityOwnerPicker />
|
||||
<EntityTagPicker />
|
||||
</FilterContainer>
|
||||
<EntityListContainer>
|
||||
<EntityListDocsTable actions={actions} columns={columns} />
|
||||
</EntityListContainer>
|
||||
</FilteredEntityLayout>
|
||||
</EntityListProvider>
|
||||
</Content>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
};
|
||||
@@ -17,11 +17,36 @@
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { DocsCardGrid } from './DocsCardGrid';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
// Hacky way to mock a specific boolean config value.
|
||||
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
useApi: (apiRef: any) => {
|
||||
const actualUseApi = jest.requireActual(
|
||||
'@backstage/core-plugin-api',
|
||||
).useApi;
|
||||
const actualApi = actualUseApi(apiRef);
|
||||
if (apiRef === configApiRef) {
|
||||
const configReader = actualApi;
|
||||
configReader.getOptionalBoolean = getOptionalBooleanMock;
|
||||
return configReader;
|
||||
}
|
||||
|
||||
return actualApi;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Entity Docs Card Grid', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render all entities passed ot it', async () => {
|
||||
const { findByText } = render(
|
||||
const { findByText, findAllByRole } = render(
|
||||
wrapInTestApp(
|
||||
<DocsCardGrid
|
||||
entities={[
|
||||
@@ -47,9 +72,58 @@ describe('Entity Docs Card Grid', () => {
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(await findByText('testName')).toBeInTheDocument();
|
||||
expect(await findByText('testName2')).toBeInTheDocument();
|
||||
const [button1, button2] = await findAllByRole('button');
|
||||
expect(button1.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind/testname',
|
||||
);
|
||||
expect(button2.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind2/testname2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to case-sensitive links when configured', async () => {
|
||||
getOptionalBooleanMock.mockReturnValue(true);
|
||||
|
||||
const { findByRole } = render(
|
||||
wrapInTestApp(
|
||||
<DocsCardGrid
|
||||
entities={[
|
||||
{
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
namespace: 'SomeNamespace',
|
||||
},
|
||||
spec: {
|
||||
owner: 'techdocs@example.com',
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const button = await findByRole('button');
|
||||
expect(getOptionalBooleanMock).toHaveBeenCalledWith(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
);
|
||||
expect(button.getAttribute('href')).toContain(
|
||||
'/techdocs/SomeNamespace/TestKind/testName',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
@@ -32,6 +32,15 @@ export const DocsCardGrid = ({
|
||||
}: {
|
||||
entities: Entity[] | undefined;
|
||||
}) => {
|
||||
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
|
||||
|
||||
// Lower-case entity triplets by default, but allow override.
|
||||
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
)
|
||||
? (str: string) => str
|
||||
: (str: string) => str.toLocaleLowerCase();
|
||||
|
||||
if (!entities) return null;
|
||||
return (
|
||||
<ItemCardGrid data-testid="docs-explore">
|
||||
@@ -45,10 +54,12 @@ export const DocsCardGrid = ({
|
||||
<CardContent>{entity.metadata.description}</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
to={generatePath(rootDocsRouteRef.path, {
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
to={getRouteToReaderPageFor({
|
||||
namespace: toLowerMaybe(
|
||||
entity.metadata.namespace ?? 'default',
|
||||
),
|
||||
kind: toLowerMaybe(entity.kind),
|
||||
name: toLowerMaybe(entity.metadata.name),
|
||||
})}
|
||||
color="primary"
|
||||
data-testid="read_docs"
|
||||
|
||||
@@ -16,9 +16,34 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { DocsTable } from './DocsTable';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
// Hacky way to mock a specific boolean config value.
|
||||
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
useApi: (apiRef: any) => {
|
||||
const actualUseApi = jest.requireActual(
|
||||
'@backstage/core-plugin-api',
|
||||
).useApi;
|
||||
const actualApi = actualUseApi(apiRef);
|
||||
if (apiRef === configApiRef) {
|
||||
const configReader = actualApi;
|
||||
configReader.getOptionalBoolean = getOptionalBooleanMock;
|
||||
return configReader;
|
||||
}
|
||||
|
||||
return actualApi;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DocsTable test', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render documents passed', async () => {
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
@@ -66,15 +91,81 @@ describe('DocsTable test', () => {
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(await findByText('testName')).toBeInTheDocument();
|
||||
expect(await findByText('testName2')).toBeInTheDocument();
|
||||
const link1 = await findByText('testName');
|
||||
const link2 = await findByText('testName2');
|
||||
expect(link1).toBeInTheDocument();
|
||||
expect(link1.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind/testname',
|
||||
);
|
||||
expect(link2).toBeInTheDocument();
|
||||
expect(link2.getAttribute('href')).toContain(
|
||||
'/docs/default/testkind2/testname2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to case-sensitive links when configured', async () => {
|
||||
getOptionalBooleanMock.mockReturnValue(true);
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(
|
||||
<DocsTable
|
||||
entities={[
|
||||
{
|
||||
apiVersion: 'version',
|
||||
kind: 'TestKind',
|
||||
metadata: {
|
||||
name: 'testName',
|
||||
namespace: 'SomeNamespace',
|
||||
},
|
||||
spec: {
|
||||
owner: 'user:owned',
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
target: {
|
||||
kind: 'user',
|
||||
namespace: 'default',
|
||||
name: 'owned',
|
||||
},
|
||||
type: 'ownedBy',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const button = await findByText('testName');
|
||||
expect(getOptionalBooleanMock).toHaveBeenCalledWith(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
);
|
||||
expect(button.getAttribute('href')).toContain(
|
||||
'/techdocs/SomeNamespace/TestKind/testName',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render empty state if no owned documents exist', async () => {
|
||||
const { findByText } = render(wrapInTestApp(<DocsTable entities={[]} />));
|
||||
const { findByText } = render(
|
||||
wrapInTestApp(<DocsTable entities={[]} />, {
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await findByText('No documents to show')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,93 +16,93 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import ShareIcon from '@material-ui/icons/Share';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import {
|
||||
formatEntityRefTitle,
|
||||
getEntityRelations,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
import {
|
||||
Table,
|
||||
EmptyState,
|
||||
Button,
|
||||
SubvalueCell,
|
||||
Link,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
} from '@backstage/core-components';
|
||||
import * as actionFactories from './actions';
|
||||
import * as columnFactories from './columns';
|
||||
import { DocsTableRow } from './types';
|
||||
|
||||
export const DocsTable = ({
|
||||
entities,
|
||||
title,
|
||||
loading,
|
||||
columns,
|
||||
actions,
|
||||
}: {
|
||||
entities: Entity[] | undefined;
|
||||
title?: string | undefined;
|
||||
loading?: boolean | undefined;
|
||||
columns?: TableColumn<DocsTableRow>[];
|
||||
actions?: TableProps<DocsTableRow>['actions'];
|
||||
}) => {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
|
||||
|
||||
// Lower-case entity triplets by default, but allow override.
|
||||
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
)
|
||||
? (str: string) => str
|
||||
: (str: string) => str.toLocaleLowerCase();
|
||||
|
||||
if (!entities) return null;
|
||||
|
||||
const documents = entities.map(entity => {
|
||||
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
|
||||
|
||||
return {
|
||||
name: entity.metadata.name,
|
||||
description: entity.metadata.description,
|
||||
owner: entity?.spec?.owner,
|
||||
type: entity?.spec?.type,
|
||||
docsUrl: generatePath(rootDocsRouteRef.path, {
|
||||
namespace: entity.metadata.namespace ?? 'default',
|
||||
kind: entity.kind,
|
||||
name: entity.metadata.name,
|
||||
}),
|
||||
entity,
|
||||
resolved: {
|
||||
docsUrl: getRouteToReaderPageFor({
|
||||
namespace: toLowerMaybe(entity.metadata.namespace ?? 'default'),
|
||||
kind: toLowerMaybe(entity.kind),
|
||||
name: toLowerMaybe(entity.metadata.name),
|
||||
}),
|
||||
ownedByRelations,
|
||||
ownedByRelationsTitle: ownedByRelations
|
||||
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
|
||||
.join(', '),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Document',
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (row: any): React.ReactNode => (
|
||||
<SubvalueCell
|
||||
value={<Link to={row.docsUrl}>{row.name}</Link>}
|
||||
subvalue={row.description}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Owner',
|
||||
field: 'owner',
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
field: 'type',
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
width: '10%',
|
||||
render: (row: any) => (
|
||||
<Tooltip title="Click to copy documentation link to clipboard">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
copyToClipboard(`${window.location.href}/${row.docsUrl}`)
|
||||
}
|
||||
>
|
||||
<ShareIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
const defaultColumns: TableColumn<DocsTableRow>[] = [
|
||||
columnFactories.createNameColumn(),
|
||||
columnFactories.createOwnerColumn(),
|
||||
columnFactories.createTypeColumn(),
|
||||
];
|
||||
|
||||
const defaultActions: TableProps<DocsTableRow>['actions'] = [
|
||||
actionFactories.createCopyDocsUrlAction(copyToClipboard),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{documents && documents.length > 0 ? (
|
||||
<Table
|
||||
{loading || (documents && documents.length > 0) ? (
|
||||
<Table<DocsTableRow>
|
||||
isLoading={loading}
|
||||
options={{
|
||||
paging: true,
|
||||
pageSize: 20,
|
||||
search: true,
|
||||
actionsColumnIndex: -1,
|
||||
}}
|
||||
data={documents}
|
||||
columns={columns}
|
||||
columns={columns || defaultColumns}
|
||||
actions={actions || defaultActions}
|
||||
title={
|
||||
title
|
||||
? `${title} (${documents.length})`
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { useCopyToClipboard } from 'react-use';
|
||||
import { capitalize } from 'lodash';
|
||||
import {
|
||||
CodeSnippet,
|
||||
TableColumn,
|
||||
TableProps,
|
||||
WarningPanel,
|
||||
} from '@backstage/core-components';
|
||||
import {
|
||||
useEntityListProvider,
|
||||
useStarredEntities,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { DocsTable } from './DocsTable';
|
||||
import * as actionFactories from './actions';
|
||||
import * as columnFactories from './columns';
|
||||
import { DocsTableRow } from './types';
|
||||
|
||||
export const EntityListDocsTable = ({
|
||||
columns,
|
||||
actions,
|
||||
}: {
|
||||
columns?: TableColumn<DocsTableRow>[];
|
||||
actions?: TableProps<DocsTableRow>['actions'];
|
||||
}) => {
|
||||
const { loading, error, entities, filters } = useEntityListProvider();
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const title = capitalize(filters.user?.value ?? 'all');
|
||||
|
||||
const defaultActions = [
|
||||
actionFactories.createCopyDocsUrlAction(copyToClipboard),
|
||||
actionFactories.createStarEntityAction(
|
||||
isStarredEntity,
|
||||
toggleStarredEntity,
|
||||
),
|
||||
];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
title="Could not load available documentation."
|
||||
>
|
||||
<CodeSnippet language="text" text={error.toString()} />
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DocsTable
|
||||
title={title}
|
||||
entities={entities}
|
||||
loading={loading}
|
||||
actions={actions || defaultActions}
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
EntityListDocsTable.columns = columnFactories;
|
||||
EntityListDocsTable.actions = actionFactories;
|
||||
+9
-3
@@ -18,7 +18,7 @@ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TechDocsHome } from './TechDocsHome';
|
||||
import { LegacyTechDocsHome } from './LegacyTechDocsHome';
|
||||
|
||||
import {
|
||||
ApiProvider,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ConfigReader,
|
||||
} from '@backstage/core-app-api';
|
||||
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
@@ -51,7 +52,7 @@ const mockCatalogApi = {
|
||||
}),
|
||||
} as Partial<CatalogApi>;
|
||||
|
||||
describe('TechDocs Home', () => {
|
||||
describe('Legacy TechDocs Home', () => {
|
||||
const configApi: ConfigApi = new ConfigReader({
|
||||
organization: {
|
||||
name: 'My Company',
|
||||
@@ -66,8 +67,13 @@ describe('TechDocs Home', () => {
|
||||
it('should render a TechDocs home page', async () => {
|
||||
await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsHome />
|
||||
<LegacyTechDocsHome />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
import React from 'react';
|
||||
import { PanelType, TechDocsCustomHome } from './TechDocsCustomHome';
|
||||
|
||||
export const TechDocsHome = () => {
|
||||
export const LegacyTechDocsHome = () => {
|
||||
const tabsConfig = [
|
||||
{
|
||||
label: 'Overview',
|
||||
@@ -20,6 +20,7 @@ import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { rootDocsRouteRef } from '../../routes';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-catalog-react');
|
||||
@@ -78,6 +79,11 @@ describe('TechDocsCustomHome', () => {
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsCustomHome tabsConfig={tabsConfig} />
|
||||
</ApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Header
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useAsync } from 'react-use';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { CSSProperties } from '@material-ui/styles';
|
||||
import {
|
||||
CATALOG_FILTER_EXISTS,
|
||||
catalogApiRef,
|
||||
CatalogApi,
|
||||
isOwnerOf,
|
||||
@@ -27,20 +28,19 @@ import {
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { DocsTable } from './DocsTable';
|
||||
import { DocsCardGrid } from './DocsCardGrid';
|
||||
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
|
||||
import {
|
||||
CodeSnippet,
|
||||
Content,
|
||||
Header,
|
||||
HeaderTabs,
|
||||
Page,
|
||||
Progress,
|
||||
WarningPanel,
|
||||
SupportButton,
|
||||
ContentHeader,
|
||||
} from '@backstage/core-components';
|
||||
|
||||
import { ConfigApi, configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const panels = {
|
||||
DocsTable: DocsTable,
|
||||
@@ -121,10 +121,16 @@ export const TechDocsCustomHome = ({
|
||||
}) => {
|
||||
const [selectedTab, setSelectedTab] = useState<number>(0);
|
||||
const catalogApi: CatalogApi = useApi(catalogApiRef);
|
||||
const configApi: ConfigApi = useApi(configApiRef);
|
||||
|
||||
const { value: entities, loading, error } = useAsync(async () => {
|
||||
const {
|
||||
value: entities,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: {
|
||||
'metadata.annotations.backstage.io/techdocs-ref': CATALOG_FILTER_EXISTS,
|
||||
},
|
||||
fields: [
|
||||
'apiVersion',
|
||||
'kind',
|
||||
@@ -139,27 +145,21 @@ export const TechDocsCustomHome = ({
|
||||
});
|
||||
});
|
||||
|
||||
const generatedSubtitle = `Documentation available in ${
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage'
|
||||
}`;
|
||||
|
||||
const currentTabConfig = tabsConfig[selectedTab];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Page themeId="documentation">
|
||||
<Header title="Documentation" subtitle={generatedSubtitle} />
|
||||
<TechDocsPageWrapper>
|
||||
<Content>
|
||||
<Progress />
|
||||
</Content>
|
||||
</Page>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Page themeId="documentation">
|
||||
<Header title="Documentation" subtitle={generatedSubtitle} />
|
||||
<TechDocsPageWrapper>
|
||||
<Content>
|
||||
<WarningPanel
|
||||
severity="error"
|
||||
@@ -168,13 +168,12 @@ export const TechDocsCustomHome = ({
|
||||
<CodeSnippet language="text" text={error.toString()} />
|
||||
</WarningPanel>
|
||||
</Content>
|
||||
</Page>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page themeId="documentation">
|
||||
<Header title="Documentation" subtitle={generatedSubtitle} />
|
||||
<TechDocsPageWrapper>
|
||||
<HeaderTabs
|
||||
selectedIndex={selectedTab}
|
||||
onChange={index => setSelectedTab(index)}
|
||||
@@ -193,6 +192,6 @@ export const TechDocsCustomHome = ({
|
||||
/>
|
||||
))}
|
||||
</Content>
|
||||
</Page>
|
||||
</TechDocsPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { renderInTestApp } from '@backstage/test-utils';
|
||||
import { useOutlet } from 'react-router';
|
||||
import { TechDocsIndexPage } from './TechDocsIndexPage';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useOutlet: jest.fn().mockReturnValue('Route Children'),
|
||||
}));
|
||||
|
||||
jest.mock('./LegacyTechDocsHome', () => ({
|
||||
LegacyTechDocsHome: jest.fn().mockReturnValue('LegacyTechDocsHomeMock'),
|
||||
}));
|
||||
|
||||
describe('TechDocsIndexPage', () => {
|
||||
it('renders provided router element', async () => {
|
||||
const { getByText } = await renderInTestApp(<TechDocsIndexPage />);
|
||||
|
||||
expect(getByText('Route Children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders legacy TechDocs home when no router children are provided', async () => {
|
||||
(useOutlet as jest.Mock).mockReturnValueOnce(null);
|
||||
const { getByText } = await renderInTestApp(<TechDocsIndexPage />);
|
||||
|
||||
expect(getByText('LegacyTechDocsHomeMock')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { useOutlet } from 'react-router';
|
||||
import { LegacyTechDocsHome } from './LegacyTechDocsHome';
|
||||
|
||||
export const TechDocsIndexPage = () => {
|
||||
const outlet = useOutlet();
|
||||
|
||||
return outlet || <LegacyTechDocsHome />;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { PageWithHeader } from '@backstage/core-components';
|
||||
import { useApi, configApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TechDocsPageWrapper = ({ children }: Props) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const generatedSubtitle = `Documentation available in ${
|
||||
configApi.getOptionalString('organization.name') ?? 'Backstage'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<PageWithHeader
|
||||
title="Documentation"
|
||||
subtitle={generatedSubtitle}
|
||||
themeId="documentation"
|
||||
>
|
||||
{children}
|
||||
</PageWithHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 } from 'react';
|
||||
import {
|
||||
CATALOG_FILTER_EXISTS,
|
||||
DefaultEntityFilters,
|
||||
EntityFilter,
|
||||
useEntityListProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
|
||||
class TechDocsFilter implements EntityFilter {
|
||||
getCatalogFilters(): Record<string, string | symbol | (string | symbol)[]> {
|
||||
return {
|
||||
'metadata.annotations.backstage.io/techdocs-ref': CATALOG_FILTER_EXISTS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type CustomFilters = DefaultEntityFilters & {
|
||||
techdocs?: TechDocsFilter;
|
||||
};
|
||||
|
||||
export const TechDocsPicker = () => {
|
||||
const { updateFilters } = useEntityListProvider<CustomFilters>();
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters({
|
||||
techdocs: new TechDocsFilter(),
|
||||
});
|
||||
}, [updateFilters]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 ShareIcon from '@material-ui/icons/Share';
|
||||
import {
|
||||
favoriteEntityIcon,
|
||||
favoriteEntityTooltip,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { DocsTableRow } from './types';
|
||||
|
||||
export function createCopyDocsUrlAction(copyToClipboard: Function) {
|
||||
return (row: DocsTableRow) => {
|
||||
return {
|
||||
icon: () => <ShareIcon fontSize="small" />,
|
||||
tooltip: 'Click to copy documentation link to clipboard',
|
||||
onClick: () =>
|
||||
copyToClipboard(
|
||||
`${window.location.origin}${window.location.pathname.replace(
|
||||
/\/?$/,
|
||||
'/',
|
||||
)}${row.resolved.docsUrl}`,
|
||||
),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createStarEntityAction(
|
||||
isStarredEntity: Function,
|
||||
toggleStarredEntity: Function,
|
||||
) {
|
||||
return ({ entity }: DocsTableRow) => {
|
||||
const isStarred = isStarredEntity(entity);
|
||||
return {
|
||||
cellStyle: { paddingLeft: '1em' },
|
||||
icon: () => favoriteEntityIcon(isStarred),
|
||||
tooltip: favoriteEntityTooltip(isStarred),
|
||||
onClick: () => toggleStarredEntity(entity),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { Link, SubvalueCell, TableColumn } from '@backstage/core-components';
|
||||
import { EntityRefLinks } from '@backstage/plugin-catalog-react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { DocsTableRow } from './types';
|
||||
|
||||
function customTitle(entity: Entity): string {
|
||||
return entity.metadata.title || entity.metadata.name;
|
||||
}
|
||||
|
||||
export function createNameColumn(): TableColumn<DocsTableRow> {
|
||||
return {
|
||||
title: 'Document',
|
||||
field: 'entity.metadata.name',
|
||||
highlight: true,
|
||||
render: (row: DocsTableRow) => (
|
||||
<SubvalueCell
|
||||
value={<Link to={row.resolved.docsUrl}>{customTitle(row.entity)}</Link>}
|
||||
subvalue={row.entity.metadata.description}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOwnerColumn(): TableColumn<DocsTableRow> {
|
||||
return {
|
||||
title: 'Owner',
|
||||
field: 'resolved.ownedByRelationsTitle',
|
||||
render: ({ resolved }) => (
|
||||
<EntityRefLinks
|
||||
entityRefs={resolved.ownedByRelations}
|
||||
defaultKind="group"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTypeColumn(): TableColumn<DocsTableRow> {
|
||||
return {
|
||||
title: 'Type',
|
||||
field: 'entity.spec.type',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { EntityListDocsTable } from './EntityListDocsTable';
|
||||
export { DefaultTechDocsHome } from './DefaultTechDocsHome';
|
||||
export { TechDocsPageWrapper } from './TechDocsPageWrapper';
|
||||
export { TechDocsPicker } from './TechDocsPicker';
|
||||
export type { PanelType } from './TechDocsCustomHome';
|
||||
export type { DocsTableRow } from './types';
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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 { Entity, EntityName } from '@backstage/catalog-model';
|
||||
|
||||
export type DocsTableRow = {
|
||||
entity: Entity;
|
||||
resolved: {
|
||||
docsUrl: string;
|
||||
ownedByRelationsTitle: string;
|
||||
ownedByRelations: EntityName[];
|
||||
};
|
||||
};
|
||||
@@ -14,20 +14,34 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Backstage plugin that renders technical documentation for your components
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './api';
|
||||
export { techdocsApiRef, techdocsStorageApiRef } from './api';
|
||||
export type { TechDocsApi, TechDocsStorageApi } from './api';
|
||||
export { TechDocsClient, TechDocsStorageClient } from './client';
|
||||
export type { PanelType } from './home/components/TechDocsCustomHome';
|
||||
export type { DocsTableRow, PanelType } from './home/components';
|
||||
export {
|
||||
EntityListDocsTable,
|
||||
DefaultTechDocsHome,
|
||||
TechDocsPageWrapper,
|
||||
TechDocsPicker,
|
||||
} from './home/components';
|
||||
export * from './components/DocsResultListItem';
|
||||
export {
|
||||
DocsCardGrid,
|
||||
DocsTable,
|
||||
EntityTechdocsContent,
|
||||
TechDocsCustomHome,
|
||||
TechDocsIndexPage,
|
||||
TechdocsPage,
|
||||
techdocsPlugin as plugin,
|
||||
techdocsPlugin,
|
||||
TechDocsReaderPage,
|
||||
} from './plugin';
|
||||
export * from './reader';
|
||||
export { EmbeddedDocsRouter, Router } from './Router';
|
||||
export { EmbeddedDocsRouter, Router, isTechDocsAvailable } from './Router';
|
||||
|
||||
@@ -65,6 +65,7 @@ export const techdocsPlugin = createPlugin({
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
docRoot: rootDocsRouteRef,
|
||||
entityContent: rootCatalogDocsRouteRef,
|
||||
},
|
||||
});
|
||||
@@ -113,6 +114,16 @@ export const TechDocsCustomHome = techdocsPlugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
export const TechDocsIndexPage = techdocsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./home/components/TechDocsIndexPage').then(
|
||||
m => m.TechDocsIndexPage,
|
||||
),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const TechDocsReaderPage = techdocsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
|
||||
@@ -25,6 +25,7 @@ import React from 'react';
|
||||
import { TechDocsStorageApi, techdocsStorageApiRef } from '../../api';
|
||||
import { Reader } from './Reader';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -34,9 +35,8 @@ jest.mock('react-router-dom', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { useParams }: { useParams: jest.Mock } = jest.requireMock(
|
||||
'react-router-dom',
|
||||
);
|
||||
const { useParams }: { useParams: jest.Mock } =
|
||||
jest.requireMock('react-router-dom');
|
||||
|
||||
describe('<Reader />', () => {
|
||||
it('should render Reader content', async () => {
|
||||
@@ -44,16 +44,23 @@ describe('<Reader />', () => {
|
||||
entityId: 'Component::backstage',
|
||||
});
|
||||
|
||||
const scmIntegrationsApi: ScmIntegrationsApi = ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {},
|
||||
}),
|
||||
);
|
||||
const scmIntegrationsApi: ScmIntegrationsApi =
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {},
|
||||
}),
|
||||
);
|
||||
const techdocsStorageApi: Partial<TechDocsStorageApi> = {};
|
||||
|
||||
const searchApi = {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
results: [],
|
||||
}),
|
||||
};
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[scmIntegrationsApiRef, scmIntegrationsApi],
|
||||
[techdocsStorageApiRef, techdocsStorageApi],
|
||||
[searchApiRef, searchApi],
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -19,7 +19,13 @@ import { Progress } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Button, CircularProgress, useTheme } from '@material-ui/core';
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
makeStyles,
|
||||
useTheme,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -38,26 +44,47 @@ import {
|
||||
} from '../transformers';
|
||||
import { TechDocsBuildLogs } from './TechDocsBuildLogs';
|
||||
import { TechDocsNotFound } from './TechDocsNotFound';
|
||||
import { TechDocsSearch } from './TechDocsSearch';
|
||||
import { useReaderState } from './useReaderState';
|
||||
|
||||
type Props = {
|
||||
entityId: EntityName;
|
||||
onReady?: () => void;
|
||||
withSearch?: boolean;
|
||||
};
|
||||
|
||||
export const Reader = ({ entityId, onReady }: Props) => {
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
message: {
|
||||
// `word-break: break-word` is deprecated, but gives legacy support to browsers not supporting `overflow-wrap` yet
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/word-break
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
searchBar: {
|
||||
marginLeft: '20rem',
|
||||
maxWidth: 'calc(100% - 20rem * 2 - 3rem)',
|
||||
marginTop: theme.spacing(1),
|
||||
'@media screen and (max-width: 76.1875em)': {
|
||||
marginLeft: '10rem',
|
||||
maxWidth: 'calc(100% - 10rem)',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const Reader = ({ entityId, onReady, withSearch = true }: Props) => {
|
||||
const { kind, namespace, name } = entityId;
|
||||
const { '*': path } = useParams();
|
||||
const theme = useTheme<BackstageTheme>();
|
||||
const classes = useStyles();
|
||||
|
||||
const {
|
||||
state,
|
||||
path,
|
||||
contentReload,
|
||||
content: rawPage,
|
||||
contentErrorMessage,
|
||||
syncErrorMessage,
|
||||
buildLog,
|
||||
} = useReaderState(kind, namespace, name, path);
|
||||
} = useReaderState(kind, namespace, name, useParams()['*']);
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
const [sidebars, setSidebars] = useState<HTMLElement[]>();
|
||||
@@ -94,31 +121,26 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
// an update to "state" might lead to an updated UI so we include it as a trigger
|
||||
}, [updateSidebarPosition, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
return;
|
||||
}
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
// Pre-render
|
||||
const transformedElement = transformer(rawPage, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: {
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
path,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
removeMkdocsHeader(),
|
||||
simplifyMkdocsFooter(),
|
||||
addGitFeedbackLink(scmIntegrationsApi),
|
||||
injectCss({
|
||||
css: `
|
||||
// a function that performs transformations that are executed prior to adding it to the DOM
|
||||
const preRender = useCallback(
|
||||
(rawContent: string, contentPath: string) =>
|
||||
transformer(rawContent, [
|
||||
sanitizeDOM(),
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: {
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
},
|
||||
path: contentPath,
|
||||
}),
|
||||
rewriteDocLinks(),
|
||||
removeMkdocsHeader(),
|
||||
simplifyMkdocsFooter(),
|
||||
addGitFeedbackLink(scmIntegrationsApi),
|
||||
injectCss({
|
||||
css: `
|
||||
body {
|
||||
font-family: ${theme.typography.fontFamily};
|
||||
--md-text-color: ${theme.palette.text.primary};
|
||||
@@ -175,21 +197,21 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Disable CSS animations on link colors as they lead to issues in dark
|
||||
// mode. The dark mode color theme is applied later and theirfore there
|
||||
// is always an animation from light to dark mode when navigation
|
||||
// between pages.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Disable CSS animations on link colors as they lead to issues in dark
|
||||
// mode. The dark mode color theme is applied later and theirfore there
|
||||
// is always an animation from light to dark mode when navigation
|
||||
// between pages.
|
||||
css: `
|
||||
.md-nav__link, .md-typeset a, .md-typeset a::before, .md-typeset .headerlink {
|
||||
transition: none;
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Properly style code blocks.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Properly style code blocks.
|
||||
css: `
|
||||
.md-typeset pre > code::-webkit-scrollbar-thumb {
|
||||
background-color: hsla(0, 0%, 0%, 0.32);
|
||||
}
|
||||
@@ -197,17 +219,17 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
background-color: hsla(0, 0%, 0%, 0.87);
|
||||
}
|
||||
`,
|
||||
}),
|
||||
injectCss({
|
||||
// Admonitions and others are using SVG masks to define icons. These
|
||||
// masks are defined as CSS variables.
|
||||
// As the MkDocs output is rendered in shadow DOM, the CSS variable
|
||||
// definitions on the root selector are not applied. Instead, the have
|
||||
// to be applied on :host.
|
||||
// As there is no way to transform the served main*.css yet (for
|
||||
// example in the backend), we have to copy from main*.css and modify
|
||||
// them.
|
||||
css: `
|
||||
}),
|
||||
injectCss({
|
||||
// Admonitions and others are using SVG masks to define icons. These
|
||||
// masks are defined as CSS variables.
|
||||
// As the MkDocs output is rendered in shadow DOM, the CSS variable
|
||||
// definitions on the root selector are not applied. Instead, the have
|
||||
// to be applied on :host.
|
||||
// As there is no way to transform the served main*.css yet (for
|
||||
// example in the backend), we have to copy from main*.css and modify
|
||||
// them.
|
||||
css: `
|
||||
:host {
|
||||
--md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>');
|
||||
--md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 5h16v2H4V5m0 4h16v2H4V9m0 4h16v2H4v-2m0 4h10v2H4v-2z"/></svg>');
|
||||
@@ -233,97 +255,129 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
--md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2m-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>');
|
||||
}
|
||||
`,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
]),
|
||||
[
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
scmIntegrationsApi,
|
||||
techdocsStorageApi,
|
||||
theme.palette.background.default,
|
||||
theme.palette.background.paper,
|
||||
theme.palette.primary.main,
|
||||
theme.palette.text.primary,
|
||||
theme.typography.fontFamily,
|
||||
],
|
||||
);
|
||||
|
||||
if (!transformedElement) {
|
||||
return; // An unexpected error occurred
|
||||
// a function that performs transformations that are executed after adding it to the DOM
|
||||
const postRender = useCallback(
|
||||
async (shadowRoot: ShadowRoot) =>
|
||||
transformer(shadowRoot.children[0], [
|
||||
dom => {
|
||||
setTimeout(() => {
|
||||
// Scoll to the desired anchor on initial navigation
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
shadowRoot?.getElementById(hash)?.scrollIntoView();
|
||||
}
|
||||
}, 200);
|
||||
return dom;
|
||||
},
|
||||
addLinkClickListener({
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
|
||||
// Scroll to hash if it's on the current page
|
||||
shadowRoot
|
||||
?.getElementById(parsedUrl.hash.slice(1))
|
||||
?.scrollIntoView();
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
}
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl: await techdocsStorageApi.getApiOrigin(),
|
||||
onLoading: (dom: Element) => {
|
||||
(dom as HTMLElement).style.setProperty('opacity', '0');
|
||||
},
|
||||
onLoaded: (dom: Element) => {
|
||||
(dom as HTMLElement).style.removeProperty('opacity');
|
||||
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
|
||||
(dom as HTMLElement)
|
||||
.querySelector('.md-nav__title')
|
||||
?.removeAttribute('for');
|
||||
const sideDivs: HTMLElement[] = Array.from(
|
||||
shadowRoot!.querySelectorAll('.md-sidebar'),
|
||||
);
|
||||
setSidebars(sideDivs);
|
||||
// set sidebar height so they don't initially render in wrong position
|
||||
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
|
||||
.top;
|
||||
const mdTabs = dom.querySelector('.md-container > .md-tabs');
|
||||
sideDivs!.forEach(sidebar => {
|
||||
sidebar.style.top = mdTabs
|
||||
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${docTopPosition}px`;
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
[navigate, techdocsStorageApi],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawPage || !shadowDomRef.current) {
|
||||
// clear the shadow dom if no content is available
|
||||
if (shadowDomRef.current?.shadowRoot) {
|
||||
shadowDomRef.current.shadowRoot.innerHTML = '';
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
if (onReady) {
|
||||
onReady();
|
||||
}
|
||||
|
||||
const shadowDiv: HTMLElement = shadowDomRef.current!;
|
||||
const shadowRoot =
|
||||
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
|
||||
Array.from(shadowRoot.children).forEach(child =>
|
||||
shadowRoot.removeChild(child),
|
||||
);
|
||||
shadowRoot.appendChild(transformedElement);
|
||||
// if false, there is already a newer execution of this effect
|
||||
let shouldReplaceContent = true;
|
||||
|
||||
// Scroll to top after render
|
||||
window.scroll({ top: 0 });
|
||||
// Pre-render
|
||||
preRender(rawPage, path).then(async transformedElement => {
|
||||
if (!transformedElement?.innerHTML) {
|
||||
return; // An unexpected error occurred
|
||||
}
|
||||
|
||||
// Post-render
|
||||
transformer(shadowRoot.children[0], [
|
||||
dom => {
|
||||
setTimeout(() => {
|
||||
// Scoll to the desired anchor on initial navigation
|
||||
if (window.location.hash) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
shadowRoot?.getElementById(hash)?.scrollIntoView();
|
||||
}
|
||||
}, 200);
|
||||
return dom;
|
||||
},
|
||||
addLinkClickListener({
|
||||
baseUrl: window.location.origin,
|
||||
onClick: (_: MouseEvent, url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
// don't manipulate the shadow dom if this isn't the latest effect execution
|
||||
if (!shouldReplaceContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedUrl.hash) {
|
||||
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
|
||||
const shadowDiv: HTMLElement = shadowDomRef.current!;
|
||||
const shadowRoot =
|
||||
shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' });
|
||||
Array.from(shadowRoot.children).forEach(child =>
|
||||
shadowRoot.removeChild(child),
|
||||
);
|
||||
shadowRoot.appendChild(transformedElement);
|
||||
|
||||
// Scroll to hash if it's on the current page
|
||||
shadowRoot
|
||||
?.getElementById(parsedUrl.hash.slice(1))
|
||||
?.scrollIntoView();
|
||||
} else {
|
||||
navigate(parsedUrl.pathname);
|
||||
}
|
||||
},
|
||||
}),
|
||||
onCssReady({
|
||||
docStorageUrl: techdocsStorageApi.getApiOrigin(),
|
||||
onLoading: (dom: Element) => {
|
||||
(dom as HTMLElement).style.setProperty('opacity', '0');
|
||||
},
|
||||
onLoaded: (dom: Element) => {
|
||||
(dom as HTMLElement).style.removeProperty('opacity');
|
||||
// disable MkDocs drawer toggling ('for' attribute => checkbox mechanism)
|
||||
(dom as HTMLElement)
|
||||
.querySelector('.md-nav__title')
|
||||
?.removeAttribute('for');
|
||||
const sideDivs: HTMLElement[] = Array.from(
|
||||
shadowRoot!.querySelectorAll('.md-sidebar'),
|
||||
);
|
||||
setSidebars(sideDivs);
|
||||
// set sidebar height so they don't initially render in wrong position
|
||||
const docTopPosition = (dom as HTMLElement).getBoundingClientRect()
|
||||
.top;
|
||||
const mdTabs = dom.querySelector('.md-container > .md-tabs');
|
||||
sideDivs!.forEach(sidebar => {
|
||||
sidebar.style.top = mdTabs
|
||||
? `${docTopPosition + mdTabs.getBoundingClientRect().height}px`
|
||||
: `${docTopPosition}px`;
|
||||
});
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}, [
|
||||
path,
|
||||
kind,
|
||||
namespace,
|
||||
name,
|
||||
rawPage,
|
||||
navigate,
|
||||
onReady,
|
||||
shadowDomRef,
|
||||
techdocsStorageApi,
|
||||
theme.typography.fontFamily,
|
||||
theme.palette.text.primary,
|
||||
theme.palette.primary.main,
|
||||
theme.palette.background.paper,
|
||||
theme.palette.background.default,
|
||||
scmIntegrationsApi,
|
||||
]);
|
||||
// Scroll to top after render
|
||||
window.scroll({ top: 0 });
|
||||
|
||||
// Post-render
|
||||
await postRender(shadowRoot);
|
||||
});
|
||||
|
||||
// cancel this execution
|
||||
return () => {
|
||||
shouldReplaceContent = false;
|
||||
};
|
||||
}, [onReady, path, postRender, preRender, rawPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -369,6 +423,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
variant="outlined"
|
||||
severity="error"
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
classes={{ message: classes.message }}
|
||||
>
|
||||
Building a newer version of this documentation failed.{' '}
|
||||
{syncErrorMessage}
|
||||
@@ -381,6 +436,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
variant="outlined"
|
||||
severity="error"
|
||||
action={<TechDocsBuildLogs buildLog={buildLog} />}
|
||||
classes={{ message: classes.message }}
|
||||
>
|
||||
Building a newer version of this documentation failed.{' '}
|
||||
{syncErrorMessage}
|
||||
@@ -389,6 +445,12 @@ export const Reader = ({ entityId, onReady }: Props) => {
|
||||
<TechDocsNotFound errorMessage={contentErrorMessage} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{withSearch && shadowDomRef?.current?.shadowRoot?.innerHTML && (
|
||||
<Grid container className={classes.searchBar}>
|
||||
<TechDocsSearch entityId={entityId} />
|
||||
</Grid>
|
||||
)}
|
||||
<div data-testid="techdocs-content-shadowroot" ref={shadowDomRef} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const TechDocsBuildLogsDrawerContent = ({
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
justify="space-between"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
spacing={0}
|
||||
wrap="nowrap"
|
||||
|
||||
@@ -23,9 +23,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const TechDocsNotFound = ({ errorMessage }: Props) => {
|
||||
const techdocsBuilder = useApi(configApiRef).getOptionalString(
|
||||
'techdocs.builder',
|
||||
);
|
||||
const techdocsBuilder =
|
||||
useApi(configApiRef).getOptionalString('techdocs.builder');
|
||||
|
||||
let additionalInfo = '';
|
||||
if (techdocsBuilder !== 'local') {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TechDocsStorageApi,
|
||||
} from '../../api';
|
||||
import { ApiRegistry, ApiProvider } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
@@ -45,9 +46,9 @@ jest.mock('./TechDocsPageHeader', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { useParams }: { useParams: jest.Mock } = jest.requireMock(
|
||||
'react-router-dom',
|
||||
);
|
||||
const { useParams }: { useParams: jest.Mock } =
|
||||
jest.requireMock('react-router-dom');
|
||||
global.scroll = jest.fn();
|
||||
|
||||
describe('<TechDocsPage />', () => {
|
||||
it('should render techdocs page', async () => {
|
||||
@@ -55,11 +56,12 @@ describe('<TechDocsPage />', () => {
|
||||
entityId: 'Component::backstage',
|
||||
});
|
||||
|
||||
const scmIntegrationsApi: ScmIntegrationsApi = ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {},
|
||||
}),
|
||||
);
|
||||
const scmIntegrationsApi: ScmIntegrationsApi =
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {},
|
||||
}),
|
||||
);
|
||||
const techdocsApi: Partial<TechDocsApi> = {
|
||||
getEntityMetadata: () =>
|
||||
Promise.resolve({
|
||||
@@ -75,16 +77,23 @@ describe('<TechDocsPage />', () => {
|
||||
site_description: 'string',
|
||||
}),
|
||||
};
|
||||
|
||||
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
|
||||
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
|
||||
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
|
||||
getApiOrigin: (): Promise<string> => Promise.resolve('String'),
|
||||
};
|
||||
|
||||
const searchApi = {
|
||||
query: () =>
|
||||
Promise.resolve({
|
||||
results: [],
|
||||
}),
|
||||
};
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[scmIntegrationsApiRef, scmIntegrationsApi],
|
||||
[techdocsApiRef, techdocsApi],
|
||||
[techdocsStorageApiRef, techdocsStorageApi],
|
||||
[searchApiRef, searchApi],
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { buildInitialFilters, TechDocsSearch } from './TechDocsSearch';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { searchApiRef } from '@backstage/plugin-search';
|
||||
|
||||
const entityId = {
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
kind: 'Testable',
|
||||
};
|
||||
|
||||
const emptyResults = Promise.resolve({
|
||||
results: [],
|
||||
});
|
||||
const singleResult = Promise.resolve({
|
||||
results: [
|
||||
{
|
||||
type: 'testsearchresult',
|
||||
document: {
|
||||
text: 'text',
|
||||
title: 'title',
|
||||
location: '/something/something',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('<TechDocsPage />', () => {
|
||||
it('should render techdocs search bar', async () => {
|
||||
const query = () => emptyResults;
|
||||
const querySpy = jest.fn(query);
|
||||
const searchApi = { query: querySpy };
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
|
||||
|
||||
await act(async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsSearch entityId={entityId} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await emptyResults;
|
||||
expect(querySpy).toBeCalled();
|
||||
expect(rendered.getByTestId('techdocs-search-bar')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('should trigger query when autocomplete input changed', async () => {
|
||||
const query = () => singleResult;
|
||||
const querySpy = jest.fn(query);
|
||||
const searchApi = { query: querySpy };
|
||||
|
||||
const apiRegistry = ApiRegistry.from([[searchApiRef, searchApi]]);
|
||||
|
||||
await act(async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<TechDocsSearch entityId={entityId} debounceTime={0} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await singleResult;
|
||||
expect(querySpy).toBeCalledWith({
|
||||
filters: {
|
||||
kind: 'testable',
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
},
|
||||
pageCursor: '',
|
||||
term: '',
|
||||
types: ['techdocs'],
|
||||
});
|
||||
|
||||
const autocomplete = rendered.getByTestId('techdocs-search-bar');
|
||||
const input = within(autocomplete).getByRole('textbox');
|
||||
autocomplete.click();
|
||||
autocomplete.focus();
|
||||
fireEvent.change(input, { target: { value: 'asdf' } });
|
||||
|
||||
await singleResult;
|
||||
await waitFor(() =>
|
||||
expect(querySpy).toBeCalledWith({
|
||||
filters: {
|
||||
kind: 'testable',
|
||||
name: 'test',
|
||||
namespace: 'testspace',
|
||||
},
|
||||
pageCursor: '',
|
||||
term: 'asdf',
|
||||
types: ['techdocs'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInitialFilters', () => {
|
||||
const filterEnt = {
|
||||
name: 'Test',
|
||||
kind: 'TestKind',
|
||||
namespace: 'TeStNaMeSpAcE',
|
||||
};
|
||||
it('should use filters as is when legacy path', () => {
|
||||
const filters = buildInitialFilters(true, filterEnt);
|
||||
expect(filters).toStrictEqual(filterEnt);
|
||||
});
|
||||
it('should lowercase all filters for new approach', () => {
|
||||
const filters = buildInitialFilters(false, filterEnt);
|
||||
expect(filters).toStrictEqual({
|
||||
name: 'test',
|
||||
kind: 'testkind',
|
||||
namespace: 'testnamespace',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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, { ChangeEvent, useEffect, useState } from 'react';
|
||||
import {
|
||||
CircularProgress,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
TextField,
|
||||
} from '@material-ui/core';
|
||||
import Autocomplete from '@material-ui/lab/Autocomplete';
|
||||
import { SearchContextProvider, useSearch } from '@backstage/plugin-search';
|
||||
import { DocsResultListItem } from '../../components/DocsResultListItem';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
type EntityId = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
};
|
||||
type TechDocsSearchProps = {
|
||||
entityId: EntityId;
|
||||
debounceTime?: number;
|
||||
};
|
||||
|
||||
type TechDocsDoc = {
|
||||
namespace: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
path: string;
|
||||
location: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type TechDocsSearchResult = {
|
||||
type: string;
|
||||
document: TechDocsDoc;
|
||||
};
|
||||
|
||||
export const buildInitialFilters = (
|
||||
legacyPaths: boolean,
|
||||
entityId: EntityId,
|
||||
) => {
|
||||
return legacyPaths
|
||||
? entityId
|
||||
: Object.entries(entityId).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [key]: value.toLocaleLowerCase('en-US') };
|
||||
}, {});
|
||||
};
|
||||
|
||||
const TechDocsSearchBar = ({
|
||||
entityId,
|
||||
debounceTime = 150,
|
||||
}: TechDocsSearchProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
term,
|
||||
setTerm,
|
||||
result: { loading, value: searchVal },
|
||||
} = useSearch();
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
if (mounted && searchVal) {
|
||||
// TODO: Change this into getting only subset of search results from the BE in the first place
|
||||
// once pagination is implemented for search engines
|
||||
// See: https://github.com/backstage/backstage/issues/6062
|
||||
const searchResults = searchVal.results.slice(0, 10);
|
||||
setOptions(searchResults);
|
||||
}
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [loading, searchVal]);
|
||||
|
||||
const [value, setValue] = useState<string>(term);
|
||||
|
||||
useDebounce(() => setTerm(value), debounceTime, [value]);
|
||||
|
||||
const handleQuery = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!open) {
|
||||
setOpen(true);
|
||||
}
|
||||
setValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleSelection = (_: any, selection: TechDocsSearchResult | null) => {
|
||||
if (selection?.document) {
|
||||
const { location } = selection.document;
|
||||
navigate(location);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid item xs={12}>
|
||||
<Autocomplete
|
||||
data-testid="techdocs-search-bar"
|
||||
size="small"
|
||||
open={open}
|
||||
getOptionLabel={() => ''}
|
||||
filterOptions={x => {
|
||||
return x; // This is needed to get renderOption to be called after options change. Bug in material-ui?
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={handleSelection}
|
||||
blurOnSelect
|
||||
noOptionsText="No results found"
|
||||
value={null}
|
||||
options={options}
|
||||
renderOption={({ document }) => (
|
||||
<DocsResultListItem
|
||||
result={document}
|
||||
lineClamp={3}
|
||||
asListItem={false}
|
||||
asLink={false}
|
||||
title={document.title}
|
||||
/>
|
||||
)}
|
||||
loading={loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
data-testid="techdocs-search-bar-input"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
placeholder={`Search ${entityId.name} docs`}
|
||||
value={value}
|
||||
onChange={handleQuery}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</React.Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const TechDocsSearch = (props: TechDocsSearchProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const legacyPaths = configApi.getOptionalBoolean(
|
||||
'techdocs.legacyUseCaseSensitiveTripletPaths',
|
||||
);
|
||||
const initialState = {
|
||||
term: '',
|
||||
types: ['techdocs'],
|
||||
pageCursor: '',
|
||||
filters: buildInitialFilters(legacyPaths || false, props.entityId),
|
||||
};
|
||||
return (
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<TechDocsSearchBar {...props} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
export { TechDocsSearch };
|
||||
@@ -86,7 +86,7 @@ describe('useReaderState', () => {
|
||||
};
|
||||
|
||||
it('should return a copy of the state', () => {
|
||||
expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({
|
||||
expect(reducer(oldState, { type: 'content', path: '/' })).toEqual({
|
||||
activeSyncState: 'CHECKING',
|
||||
contentLoading: false,
|
||||
path: '/',
|
||||
@@ -102,13 +102,13 @@ describe('useReaderState', () => {
|
||||
});
|
||||
|
||||
it.each`
|
||||
type | oldActiveSyncState | newActiveSyncState
|
||||
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
|
||||
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
|
||||
${'navigate'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
|
||||
${'navigate'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
|
||||
${'sync'} | ${'BUILD_READY'} | ${undefined}
|
||||
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined}
|
||||
type | oldActiveSyncState | newActiveSyncState
|
||||
${'contentLoading'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
|
||||
${'contentLoading'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
|
||||
${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'}
|
||||
${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'}
|
||||
${'sync'} | ${'BUILD_READY'} | ${undefined /* undefined, because we don't set an input */}
|
||||
${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined /* undefined, because we don't set an input */}
|
||||
`(
|
||||
'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState',
|
||||
({ type, oldActiveSyncState, newActiveSyncState }) => {
|
||||
@@ -124,18 +124,45 @@ describe('useReaderState', () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe('"content" action', () => {
|
||||
describe('"contentLoading" action', () => {
|
||||
it('should set loading', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'contentLoading',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentLoading: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep content', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
content: 'some-old-content',
|
||||
},
|
||||
{
|
||||
type: 'contentLoading',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentLoading: true,
|
||||
content: 'some-old-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset errors', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentError: new Error(),
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
contentLoading: true,
|
||||
type: 'contentLoading',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
@@ -143,7 +170,9 @@ describe('useReaderState', () => {
|
||||
contentLoading: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"content" action', () => {
|
||||
it('should set content', () => {
|
||||
expect(
|
||||
reducer(
|
||||
@@ -164,6 +193,27 @@ describe('useReaderState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set content and update path', () => {
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
...oldState,
|
||||
contentLoading: true,
|
||||
},
|
||||
{
|
||||
type: 'content',
|
||||
content: 'asdf',
|
||||
path: '/new-path',
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
contentLoading: false,
|
||||
content: 'asdf',
|
||||
path: '/new-path',
|
||||
});
|
||||
});
|
||||
|
||||
it('should set error', () => {
|
||||
expect(
|
||||
reducer(
|
||||
@@ -185,20 +235,6 @@ describe('useReaderState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('"navigate" action', () => {
|
||||
it('should work', () => {
|
||||
expect(
|
||||
reducer(oldState, {
|
||||
type: 'navigate',
|
||||
path: '/',
|
||||
}),
|
||||
).toEqual({
|
||||
...oldState,
|
||||
path: '/',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('"sync" action', () => {
|
||||
it('should update state', () => {
|
||||
expect(
|
||||
@@ -256,6 +292,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -267,6 +304,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -313,6 +351,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -324,6 +363,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'INITIAL_BUILD',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: 'NotFoundError: Page Not Found',
|
||||
syncErrorMessage: undefined,
|
||||
@@ -335,6 +375,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -346,6 +387,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -394,6 +436,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -405,6 +448,7 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -416,6 +460,7 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_REFRESHING',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -427,6 +472,7 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_STALE_READY',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -441,7 +487,8 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
content: undefined,
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
@@ -452,6 +499,7 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/example',
|
||||
content: 'my new content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -475,6 +523,103 @@ describe('useReaderState', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle navigation', async () => {
|
||||
techdocsStorageApi.getEntityDocs
|
||||
.mockResolvedValueOnce('my content')
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
return 'my new content';
|
||||
})
|
||||
.mockRejectedValueOnce(new NotFoundError('Some error description'));
|
||||
techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForValueToChange, rerender } = await renderHook(
|
||||
({ path }: { path: string }) =>
|
||||
useReaderState('Component', 'default', 'backstage', path),
|
||||
{ initialProps: { path: '/example' }, wrapper: Wrapper as any },
|
||||
);
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// show the content
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// navigate
|
||||
rerender({ path: '/new' });
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: 'my content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_FRESH',
|
||||
path: '/new',
|
||||
content: 'my new content',
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
// navigate
|
||||
rerender({ path: '/missing' });
|
||||
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_NOT_FOUND',
|
||||
path: '/missing',
|
||||
content: undefined,
|
||||
contentErrorMessage: 'NotFoundError: Some error description',
|
||||
syncErrorMessage: undefined,
|
||||
buildLog: [],
|
||||
contentReload: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/example',
|
||||
);
|
||||
expect(techdocsStorageApi.getEntityDocs).toBeCalledWith(
|
||||
{ kind: 'Component', namespace: 'default', name: 'backstage' },
|
||||
'/new',
|
||||
);
|
||||
expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith(
|
||||
{
|
||||
kind: 'Component',
|
||||
namespace: 'default',
|
||||
name: 'backstage',
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle content error', async () => {
|
||||
techdocsStorageApi.getEntityDocs.mockRejectedValue(
|
||||
new NotFoundError('Some error description'),
|
||||
@@ -489,6 +634,7 @@ describe('useReaderState', () => {
|
||||
|
||||
expect(result.current).toEqual({
|
||||
state: 'CHECKING',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: undefined,
|
||||
syncErrorMessage: undefined,
|
||||
@@ -500,6 +646,7 @@ describe('useReaderState', () => {
|
||||
await waitForValueToChange(() => result.current.state);
|
||||
expect(result.current).toEqual({
|
||||
state: 'CONTENT_NOT_FOUND',
|
||||
path: '/example',
|
||||
content: undefined,
|
||||
contentErrorMessage: 'NotFoundError: Some error description',
|
||||
syncErrorMessage: undefined,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useMemo, useReducer, useRef } from 'react';
|
||||
import { useAsync, useAsyncRetry } from 'react-use';
|
||||
import { techdocsStorageApiRef } from '../../api';
|
||||
|
||||
@@ -131,13 +131,13 @@ type ReducerActions =
|
||||
state: SyncStates;
|
||||
syncError?: Error;
|
||||
}
|
||||
| { type: 'contentLoading' }
|
||||
| {
|
||||
type: 'content';
|
||||
path?: string;
|
||||
content?: string;
|
||||
contentLoading?: true;
|
||||
contentError?: Error;
|
||||
}
|
||||
| { type: 'navigate'; path: string }
|
||||
| { type: 'buildLog'; log: string };
|
||||
|
||||
type ReducerState = {
|
||||
@@ -186,14 +186,22 @@ export function reducer(
|
||||
newState.syncError = action.syncError;
|
||||
break;
|
||||
|
||||
case 'content':
|
||||
newState.content = action.content;
|
||||
newState.contentLoading = action.contentLoading ?? false;
|
||||
newState.contentError = action.contentError;
|
||||
case 'contentLoading':
|
||||
newState.contentLoading = true;
|
||||
|
||||
// only reset errors but keep the old content until it is replaced by the 'content' action
|
||||
newState.contentError = undefined;
|
||||
break;
|
||||
|
||||
case 'navigate':
|
||||
newState.path = action.path;
|
||||
case 'content':
|
||||
// only override the path if it is part of the action
|
||||
if (typeof action.path === 'string') {
|
||||
newState.path = action.path;
|
||||
}
|
||||
|
||||
newState.contentLoading = false;
|
||||
newState.content = action.content;
|
||||
newState.contentError = action.contentError;
|
||||
break;
|
||||
|
||||
case 'buildLog':
|
||||
@@ -204,10 +212,10 @@ export function reducer(
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
// a navigation or a content update loads fresh content so the build is updated to being up-to-date
|
||||
// a content update loads fresh content so the build is updated to being up-to-date
|
||||
if (
|
||||
['BUILD_READY', 'BUILD_READY_RELOAD'].includes(newState.activeSyncState) &&
|
||||
['content', 'navigate'].includes(action.type)
|
||||
['contentLoading', 'content'].includes(action.type)
|
||||
) {
|
||||
newState.activeSyncState = 'UP_TO_DATE';
|
||||
newState.buildLog = [];
|
||||
@@ -223,6 +231,7 @@ export function useReaderState(
|
||||
path: string,
|
||||
): {
|
||||
state: ContentStateTypes;
|
||||
path: string;
|
||||
contentReload: () => void;
|
||||
content?: string;
|
||||
contentErrorMessage?: string;
|
||||
@@ -238,14 +247,9 @@ export function useReaderState(
|
||||
|
||||
const techdocsStorageApi = useApi(techdocsStorageApiRef);
|
||||
|
||||
// convert all path changes into actions
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'navigate', path });
|
||||
}, [path]);
|
||||
|
||||
// try to load the content. the function will fire events and we don't care for the return values
|
||||
const { retry: contentReload } = useAsyncRetry(async () => {
|
||||
dispatch({ type: 'content', contentLoading: true });
|
||||
dispatch({ type: 'contentLoading' });
|
||||
|
||||
try {
|
||||
const entityDocs = await techdocsStorageApi.getEntityDocs(
|
||||
@@ -253,11 +257,12 @@ export function useReaderState(
|
||||
path,
|
||||
);
|
||||
|
||||
dispatch({ type: 'content', content: entityDocs });
|
||||
// update content and path at the same time
|
||||
dispatch({ type: 'content', content: entityDocs, path });
|
||||
|
||||
return entityDocs;
|
||||
} catch (e) {
|
||||
dispatch({ type: 'content', contentError: e });
|
||||
dispatch({ type: 'content', contentError: e, path });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -335,6 +340,7 @@ export function useReaderState(
|
||||
return {
|
||||
state: displayState,
|
||||
contentReload,
|
||||
path: state.path,
|
||||
content: state.content,
|
||||
contentErrorMessage: state.contentError?.toString(),
|
||||
syncErrorMessage: state.syncError?.toString(),
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { createTestShadowDom } from '../../test-utils';
|
||||
import { addBaseUrl } from '../transformers';
|
||||
import { TechDocsStorageApi } from '../../api';
|
||||
import { createTestShadowDom } from '../../test-utils';
|
||||
import { addBaseUrl } from './addBaseUrl';
|
||||
|
||||
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
|
||||
const API_ORIGIN_URL = 'https://backstage.example.com/api/techdocs';
|
||||
@@ -62,8 +62,8 @@ describe('addBaseUrl', () => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('contains relative paths', () => {
|
||||
createTestShadowDom(fixture, {
|
||||
it('contains relative paths', async () => {
|
||||
await createTestShadowDom(fixture, {
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
@@ -110,7 +110,7 @@ describe('addBaseUrl', () => {
|
||||
text: jest.fn().mockResolvedValue(svgContent),
|
||||
});
|
||||
|
||||
const root = createTestShadowDom('<img id="x" src="test.svg" />', {
|
||||
const root = await createTestShadowDom('<img id="x" src="test.svg" />', {
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
@@ -137,7 +137,7 @@ describe('addBaseUrl', () => {
|
||||
text: jest.fn().mockResolvedValue(svgContent),
|
||||
});
|
||||
|
||||
const root = createTestShadowDom(
|
||||
const root = await createTestShadowDom(
|
||||
`<img id="x" src="${API_ORIGIN_URL}/test.svg" />`,
|
||||
{
|
||||
preTransformers: [
|
||||
@@ -162,16 +162,19 @@ describe('addBaseUrl', () => {
|
||||
|
||||
it('does not inline external svgs', async () => {
|
||||
const expectedSrc = 'https://example.com/test.svg';
|
||||
const root = createTestShadowDom(`<img id="x" src="${expectedSrc}" />`, {
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: mockEntityId,
|
||||
path: '',
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
});
|
||||
const root = await createTestShadowDom(
|
||||
`<img id="x" src="${expectedSrc}" />`,
|
||||
{
|
||||
preTransformers: [
|
||||
addBaseUrl({
|
||||
techdocsStorageApi,
|
||||
entityId: mockEntityId,
|
||||
path: '',
|
||||
}),
|
||||
],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise<void>(done => {
|
||||
process.nextTick(() => {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityName } from '@backstage/catalog-model';
|
||||
import type { Transformer } from './transformer';
|
||||
import { TechDocsStorageApi } from '../../api';
|
||||
import type { Transformer } from './transformer';
|
||||
|
||||
type AddBaseUrlOptions = {
|
||||
techdocsStorageApi: TechDocsStorageApi;
|
||||
@@ -44,14 +44,15 @@ export const addBaseUrl = ({
|
||||
entityId,
|
||||
path,
|
||||
}: AddBaseUrlOptions): Transformer => {
|
||||
return dom => {
|
||||
const updateDom = <T extends Element>(
|
||||
return async dom => {
|
||||
const apiOrigin = await techdocsStorageApi.getApiOrigin();
|
||||
|
||||
const updateDom = async <T extends Element>(
|
||||
list: HTMLCollectionOf<T> | NodeListOf<T>,
|
||||
attributeName: string,
|
||||
): void => {
|
||||
Array.from(list)
|
||||
.filter(elem => !!elem.getAttribute(attributeName))
|
||||
.forEach(async (elem: T) => {
|
||||
) => {
|
||||
for (const elem of list) {
|
||||
if (elem.hasAttribute(attributeName)) {
|
||||
const elemAttribute = elem.getAttribute(attributeName);
|
||||
if (!elemAttribute) return;
|
||||
|
||||
@@ -61,7 +62,7 @@ export const addBaseUrl = ({
|
||||
entityId,
|
||||
path,
|
||||
);
|
||||
const apiOrigin = await techdocsStorageApi.getApiOrigin();
|
||||
|
||||
if (isSvgNeedingInlining(attributeName, elemAttribute, apiOrigin)) {
|
||||
try {
|
||||
const svg = await fetch(newValue, { credentials: 'include' });
|
||||
@@ -76,13 +77,16 @@ export const addBaseUrl = ({
|
||||
} else {
|
||||
elem.setAttribute(attributeName, newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src');
|
||||
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src');
|
||||
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href');
|
||||
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href');
|
||||
await Promise.all([
|
||||
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src'),
|
||||
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src'),
|
||||
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href'),
|
||||
updateDom<HTMLAnchorElement>(dom.querySelectorAll('a[download]'), 'href'),
|
||||
]);
|
||||
|
||||
return dom;
|
||||
};
|
||||
|
||||
@@ -28,14 +28,14 @@ const integrations = ScmIntegrations.fromConfig(
|
||||
);
|
||||
|
||||
describe('addGitFeedbackLink', () => {
|
||||
it('adds a feedback link when a Gitlab source edit link is available', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('adds a feedback link when a Gitlab source edit link is available', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<article class="md-content__inner">
|
||||
<h1>HeaderText</h1>
|
||||
<a title="Edit this page" href="https://gitlab.com/reponame/username/docs/TestDoc.md"></>
|
||||
<a title="Edit this page" href="https://gitlab.com/groupname/reponame/-/blob/master/docs/docname.md"></>
|
||||
</article>
|
||||
</html>
|
||||
`,
|
||||
@@ -49,18 +49,18 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(
|
||||
(shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
|
||||
).toEqual(
|
||||
'https://gitlab.com/reponame/username/issues/new?issue[title]=Documentation%20Feedback%3A%20HeaderText&issue[description]=Page%20source%3A%0Ahttps%3A%2F%2Fgitlab.com%2Freponame%2Fusername%2Fdocs%2FTestDoc.md%0A%0AFeedback%3A',
|
||||
'https://gitlab.com/groupname/reponame/issues/new?issue[title]=Documentation%20Feedback%3A%20HeaderText&issue[description]=Page%20source%3A%0Ahttps%3A%2F%2Fgitlab.com%2Fgroupname%2Freponame%2F-%2Fblob%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a feedback link when a Github source edit link is available', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('adds a feedback link correctly when a Gitlab source edit link is available and contains a subgroup', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<article class="md-content__inner">
|
||||
<h1>HeaderText</h1>
|
||||
<a title="Edit this page" href="https://github.com/reponame/username/docs/TestDoc.md"></>
|
||||
<a title="Edit this page" href="https://gitlab.com/groupname/subgroupname/reponame/-/blob/master/docs/docname.md"></>
|
||||
</article>
|
||||
</html>
|
||||
`,
|
||||
@@ -74,12 +74,37 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(
|
||||
(shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
|
||||
).toEqual(
|
||||
'https://github.com/reponame/username/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fgithub.com%2Freponame%2Fusername%2Fdocs%2FTestDoc.md%0A%0AFeedback%3A',
|
||||
'https://gitlab.com/groupname/subgroupname/reponame/issues/new?issue[title]=Documentation%20Feedback%3A%20HeaderText&issue[description]=Page%20source%3A%0Ahttps%3A%2F%2Fgitlab.com%2Fgroupname%2Fsubgroupname%2Freponame%2F-%2Fblob%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a feedback link when no source edit link is available', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('adds a feedback link when a Github source edit link is available', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<article class="md-content__inner">
|
||||
<h1>HeaderText</h1>
|
||||
<a title="Edit this page" href="https://github.com/groupname/reponame/edit/master/docs/docname.md"></>
|
||||
</article>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
preTransformers: [addGitFeedbackLink(integrations)],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelector('#git-feedback-link')).toBeTruthy();
|
||||
expect(
|
||||
(shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
|
||||
).toEqual(
|
||||
'https://github.com/groupname/reponame/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fgithub.com%2Fgroupname%2Freponame%2Fedit%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a feedback link when no source edit link is available', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -97,8 +122,8 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not add a feedback link when a Gitlab or Github source edit link is not available', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('does not add a feedback link when a Gitlab or Github source edit link is not available', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -117,14 +142,14 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(shadowDom.querySelector('#git-feedback-link')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('adds a feedback link when a Gitlab or Github source edit link is not available but hostname matches an integrations host', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<article class="md-content__inner">
|
||||
<h1>HeaderText<a class="headerlink" href="http://headerlink.com">¶</a></h1>
|
||||
<a title="Edit this page" href="https://self-hosted-git-hub-provider.com/reponame/username/docs/TestDoc.md"/>
|
||||
<a title="Edit this page" href="https://self-hosted-git-hub-provider.com/groupname/reponame/blob/master/docs/docname.md"/>
|
||||
</article>
|
||||
</html>
|
||||
`,
|
||||
@@ -138,7 +163,7 @@ describe('addGitFeedbackLink', () => {
|
||||
expect(
|
||||
(shadowDom.querySelector('#git-feedback-link') as HTMLLinkElement)!.href,
|
||||
).toEqual(
|
||||
'https://self-hosted-git-hub-provider.com/reponame/username/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fself-hosted-git-hub-provider.com%2Freponame%2Fusername%2Fdocs%2FTestDoc.md%0A%0AFeedback%3A',
|
||||
'https://self-hosted-git-hub-provider.com/groupname/reponame/issues/new?title=Documentation%20Feedback%3A%20HeaderText&body=Page%20source%3A%0Ahttps%3A%2F%2Fself-hosted-git-hub-provider.com%2Fgroupname%2Freponame%2Fblob%2Fmaster%2Fdocs%2Fdocname.md%0A%0AFeedback%3A',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
*/
|
||||
|
||||
import type { Transformer } from './index';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import {
|
||||
replaceGitHubUrlType,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
// requires repo
|
||||
export const addGitFeedbackLink = (
|
||||
@@ -50,7 +54,14 @@ export const addGitFeedbackLink = (
|
||||
const issueDesc = encodeURIComponent(
|
||||
`Page source:\n${sourceAnchor.href}\n\nFeedback:`,
|
||||
);
|
||||
const repoPath = sourceURL.pathname.split('/').slice(0, 3).join('/');
|
||||
|
||||
// Convert GitHub edit url to blob type so it can be parsed by git-url-parse correctly
|
||||
const gitUrl =
|
||||
integration?.type === 'github'
|
||||
? replaceGitHubUrlType(sourceURL.href, 'blob')
|
||||
: sourceURL.href;
|
||||
const gitInfo = parseGitUrl(gitUrl);
|
||||
const repoPath = `/${gitInfo.organization}/${gitInfo.name}`;
|
||||
|
||||
const feedbackLink = sourceAnchor.cloneNode() as HTMLAnchorElement;
|
||||
switch (integration?.type) {
|
||||
|
||||
@@ -18,9 +18,9 @@ import { createTestShadowDom } from '../../test-utils';
|
||||
import { addLinkClickListener } from './addLinkClickListener';
|
||||
|
||||
describe('addLinkClickListener', () => {
|
||||
it('calls onClick when a link has been clicked', () => {
|
||||
it('calls onClick when a link has been clicked', async () => {
|
||||
const fn = jest.fn();
|
||||
const shadowDom = createTestShadowDom(
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -45,9 +45,9 @@ describe('addLinkClickListener', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClick when a link links to another baseUrl', () => {
|
||||
it('does not call onClick when a link links to another baseUrl', async () => {
|
||||
const fn = jest.fn();
|
||||
const shadowDom = createTestShadowDom(
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
import { Transformer, transform } from './transformer';
|
||||
|
||||
describe('transform', () => {
|
||||
it('calls the transformers', () => {
|
||||
it('calls the transformers', async () => {
|
||||
const fn = jest.fn();
|
||||
const mockTransformer = (): Transformer => (dom: Element) => {
|
||||
fn(dom);
|
||||
return dom;
|
||||
};
|
||||
|
||||
transform('<html></html>', [mockTransformer()]);
|
||||
await transform('<html></html>', [mockTransformer()]);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith(expect.any(Element));
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { createTestShadowDom } from '../../test-utils';
|
||||
import { injectCss } from '../transformers';
|
||||
import { injectCss } from './injectCss';
|
||||
|
||||
describe('injectCss', () => {
|
||||
it('should inject style with passed css in head', () => {
|
||||
it('should inject style with passed css in head', async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head></head>
|
||||
@@ -27,7 +27,7 @@ describe('injectCss', () => {
|
||||
`;
|
||||
const injectedCss = '* {background-color: #fff}';
|
||||
|
||||
const shadowDom = createTestShadowDom(html, {
|
||||
const shadowDom = await createTestShadowDom(html, {
|
||||
preTransformers: [injectCss({ css: injectedCss })],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
@@ -15,16 +15,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createTestShadowDom,
|
||||
mockStylesheetEventListener,
|
||||
executeStylesheetEventListeners,
|
||||
clearStylesheetEventListeners,
|
||||
createTestShadowDom,
|
||||
executeStylesheetEventListeners,
|
||||
mockStylesheetEventListener,
|
||||
} from '../../test-utils';
|
||||
import { onCssReady } from '../transformers';
|
||||
import { onCssReady } from './onCssReady';
|
||||
|
||||
const docStorageUrl: Promise<string> = Promise.resolve(
|
||||
'https://techdocs-mock-sites.storage.googleapis.com',
|
||||
);
|
||||
const docStorageUrl: string =
|
||||
'https://techdocs-mock-sites.storage.googleapis.com';
|
||||
|
||||
const fixture = `
|
||||
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
|
||||
@@ -48,11 +47,11 @@ describe('onCssReady', () => {
|
||||
clearStylesheetEventListeners();
|
||||
});
|
||||
|
||||
it('does not call onLoading and onLoaded without the onCssReady transformer', () => {
|
||||
it('does not call onLoading and onLoaded without the onCssReady transformer', async () => {
|
||||
const onLoading = jest.fn();
|
||||
const onLoaded = jest.fn();
|
||||
|
||||
createTestShadowDom(fixture, {
|
||||
await createTestShadowDom(fixture, {
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
});
|
||||
@@ -62,11 +61,11 @@ describe('onCssReady', () => {
|
||||
expect(onLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the onLoading and onLoaded correctly', () => {
|
||||
it('calls the onLoading and onLoaded correctly', async () => {
|
||||
const onLoading = jest.fn();
|
||||
const onLoaded = jest.fn();
|
||||
|
||||
createTestShadowDom(fixture, {
|
||||
await createTestShadowDom(fixture, {
|
||||
preTransformers: [],
|
||||
postTransformers: [
|
||||
onCssReady({
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { Transformer } from './transformer';
|
||||
|
||||
type OnCssReadyOptions = {
|
||||
docStorageUrl: Promise<string>;
|
||||
docStorageUrl: string;
|
||||
onLoading: (dom: Element) => void;
|
||||
onLoaded: (dom: Element) => void;
|
||||
};
|
||||
@@ -30,9 +30,7 @@ export const onCssReady = ({
|
||||
return dom => {
|
||||
const cssPages = Array.from(
|
||||
dom.querySelectorAll('head > link[rel="stylesheet"]'),
|
||||
).filter(async elem =>
|
||||
elem.getAttribute('href')?.startsWith(await docStorageUrl),
|
||||
);
|
||||
).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl));
|
||||
|
||||
let count = cssPages.length;
|
||||
|
||||
|
||||
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
|
||||
import { removeMkdocsHeader } from '../transformers';
|
||||
|
||||
describe('removeMkdocsHeader', () => {
|
||||
it('does not remove mkdocs header', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
});
|
||||
it('does not remove mkdocs header', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does remove mkdocs header', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [removeMkdocsHeader()],
|
||||
postTransformers: [],
|
||||
});
|
||||
it('does remove mkdocs header', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [removeMkdocsHeader()],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -19,8 +19,8 @@ import { rewriteDocLinks } from '../transformers';
|
||||
import { normalizeUrl } from './rewriteDocLinks';
|
||||
|
||||
describe('rewriteDocLinks', () => {
|
||||
it('should not do anything', () => {
|
||||
const shadowDom = createTestShadowDom(`
|
||||
it('should not do anything', async () => {
|
||||
const shadowDom = await createTestShadowDom(`
|
||||
<a href="http://example.org/">Test</a>
|
||||
<a href="../example">Test</a>
|
||||
<a href="example-docs">Test</a>
|
||||
@@ -35,8 +35,8 @@ describe('rewriteDocLinks', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should transform a href with localhost as baseUrl', () => {
|
||||
const shadowDom = createTestShadowDom(
|
||||
it('should transform a href with localhost as baseUrl', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`
|
||||
<a href="http://example.org/">Test</a>
|
||||
<a href="../example">Test</a>
|
||||
@@ -57,9 +57,9 @@ describe('rewriteDocLinks', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should rewrite non-parseable URLs as text', () => {
|
||||
it('should rewrite non-parseable URLs as text', async () => {
|
||||
const expectedText = `www.my-internet.[top-level-domain]/pathname/[URLkey]`;
|
||||
const shadowDom = createTestShadowDom(
|
||||
const shadowDom = await createTestShadowDom(
|
||||
`<a href="http://${expectedText}">${expectedText}</a>`,
|
||||
{
|
||||
preTransformers: [rewriteDocLinks()],
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { createTestShadowDom, FIXTURES } from '../../test-utils';
|
||||
import { Transformer } from './index';
|
||||
import { sanitizeDOM } from './sanitizeDOM';
|
||||
|
||||
const injectMaliciousLink = (): Transformer => dom => {
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('id', 'test-malicious-link');
|
||||
link.setAttribute('onclick', 'alert("Hello world");');
|
||||
dom.querySelector('body')?.appendChild(link);
|
||||
return dom;
|
||||
};
|
||||
|
||||
describe('sanitizeDOM', () => {
|
||||
it('contains a script tag', async () => {
|
||||
const shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
|
||||
|
||||
expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not contain a script tag', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelectorAll('script').length).toBe(0);
|
||||
});
|
||||
|
||||
it('contains link with a onClick attribute', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [injectMaliciousLink()],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not contain link with a onClick attribute', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('removes style tags', async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<style>* {color: #f0f;}</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const shadowDom = await createTestShadowDom(html, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelectorAll('style').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('does not remove link tags', async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const shadowDom = await createTestShadowDom(html, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelectorAll('link').length).toEqual(1);
|
||||
});
|
||||
|
||||
describe('safe head links', () => {
|
||||
let shadowDom: ShadowRoot;
|
||||
|
||||
beforeEach(async () => {
|
||||
shadowDom = await createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not sanitize the techdocs css', async () => {
|
||||
const techdocsCss = shadowDom.querySelector(
|
||||
'link[href$="main.fe0cca5b.min.css"]',
|
||||
);
|
||||
const rel = techdocsCss!.getAttribute('rel');
|
||||
expect(rel).toBe('stylesheet');
|
||||
});
|
||||
|
||||
it('should not sanitize google fonts', async () => {
|
||||
const googleFonts = shadowDom.querySelector(
|
||||
'link[href^="https://fonts.googleapis.com"]',
|
||||
);
|
||||
const rel = googleFonts!.getAttribute('rel');
|
||||
expect(rel).toBe('stylesheet');
|
||||
});
|
||||
|
||||
it('should not sanitize gstatic fonts', async () => {
|
||||
const gstaticFonts = shadowDom.querySelector(
|
||||
'link[href^="https://fonts.gstatic.com"]',
|
||||
);
|
||||
const rel = gstaticFonts!.getAttribute('rel');
|
||||
expect(rel).toBe('preconnect');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const TECHDOCS_CSS = /main\.[A-Fa-f0-9]{8}\.min\.css$/;
|
||||
const GOOGLE_FONTS = /^https:\/\/fonts\.googleapis\.com/;
|
||||
const GSTATIC_FONTS = /^https:\/\/fonts\.gstatic\.com/;
|
||||
|
||||
export const safeLinksHook = (node: Element) => {
|
||||
if (node.nodeName && node.nodeName === 'LINK') {
|
||||
const href = node.getAttribute('href') || '';
|
||||
if (href.match(TECHDOCS_CSS)) {
|
||||
node.setAttribute('rel', 'stylesheet');
|
||||
}
|
||||
if (href.match(GOOGLE_FONTS)) {
|
||||
node.setAttribute('rel', 'stylesheet');
|
||||
}
|
||||
if (href.match(GSTATIC_FONTS)) {
|
||||
node.setAttribute('rel', 'preconnect');
|
||||
}
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Transformer } from './transformer';
|
||||
|
||||
export const sanitizeDOM = (): Transformer => {
|
||||
return dom => {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', safeLinksHook);
|
||||
return DOMPurify.sanitize(dom.innerHTML, {
|
||||
ADD_TAGS: ['link'],
|
||||
FORBID_TAGS: ['style'],
|
||||
WHOLE_DOCUMENT: true,
|
||||
RETURN_DOM: true,
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,386 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is the source of truth of what attributes we support in TechDocs.
|
||||
*/
|
||||
|
||||
export const html = [
|
||||
'accept',
|
||||
'action',
|
||||
'align',
|
||||
'alt',
|
||||
'autocapitalize',
|
||||
'autocomplete',
|
||||
'autopictureinpicture',
|
||||
'autoplay',
|
||||
'background',
|
||||
'bgcolor',
|
||||
'border',
|
||||
'capture',
|
||||
'cellpadding',
|
||||
'cellspacing',
|
||||
'checked',
|
||||
'cite',
|
||||
'class',
|
||||
'clear',
|
||||
'color',
|
||||
'cols',
|
||||
'colspan',
|
||||
'controls',
|
||||
'controlslist',
|
||||
'coords',
|
||||
'crossorigin',
|
||||
'datetime',
|
||||
'decoding',
|
||||
'default',
|
||||
'dir',
|
||||
'disabled',
|
||||
'disablepictureinpicture',
|
||||
'disableremoteplayback',
|
||||
'download',
|
||||
'draggable',
|
||||
'enctype',
|
||||
'enterkeyhint',
|
||||
'face',
|
||||
'for',
|
||||
'headers',
|
||||
'height',
|
||||
'hidden',
|
||||
'high',
|
||||
'href',
|
||||
'hreflang',
|
||||
'id',
|
||||
'inputmode',
|
||||
'integrity',
|
||||
'ismap',
|
||||
'kind',
|
||||
'label',
|
||||
'lang',
|
||||
'list',
|
||||
'loading',
|
||||
'loop',
|
||||
'low',
|
||||
'max',
|
||||
'maxlength',
|
||||
'media',
|
||||
'method',
|
||||
'min',
|
||||
'minlength',
|
||||
'multiple',
|
||||
'muted',
|
||||
'name',
|
||||
'noshade',
|
||||
'novalidate',
|
||||
'nowrap',
|
||||
'open',
|
||||
'optimum',
|
||||
'pattern',
|
||||
'placeholder',
|
||||
'playsinline',
|
||||
'poster',
|
||||
'preload',
|
||||
'pubdate',
|
||||
'radiogroup',
|
||||
'readonly',
|
||||
'rel',
|
||||
'required',
|
||||
'rev',
|
||||
'reversed',
|
||||
'role',
|
||||
'rows',
|
||||
'rowspan',
|
||||
'spellcheck',
|
||||
'scope',
|
||||
'selected',
|
||||
'shape',
|
||||
'size',
|
||||
'sizes',
|
||||
'span',
|
||||
'srclang',
|
||||
'start',
|
||||
'src',
|
||||
'srcset',
|
||||
'step',
|
||||
'style',
|
||||
'summary',
|
||||
'tabindex',
|
||||
'title',
|
||||
'translate',
|
||||
'type',
|
||||
'usemap',
|
||||
'valign',
|
||||
'value',
|
||||
'width',
|
||||
'xmlns',
|
||||
];
|
||||
|
||||
export const svg = [
|
||||
'accent-height',
|
||||
'accumulate',
|
||||
'additive',
|
||||
'alignment-baseline',
|
||||
'ascent',
|
||||
'attributename',
|
||||
'attributetype',
|
||||
'azimuth',
|
||||
'basefrequency',
|
||||
'baseline-shift',
|
||||
'begin',
|
||||
'bias',
|
||||
'by',
|
||||
'class',
|
||||
'clip',
|
||||
'clip-path',
|
||||
'clip-rule',
|
||||
'color',
|
||||
'color-interpolation',
|
||||
'color-interpolation-filters',
|
||||
'color-profile',
|
||||
'color-rendering',
|
||||
'cx',
|
||||
'cy',
|
||||
'd',
|
||||
'dx',
|
||||
'dy',
|
||||
'data',
|
||||
'diffuseconstant',
|
||||
'direction',
|
||||
'display',
|
||||
'divisor',
|
||||
'dur',
|
||||
'edgemode',
|
||||
'elevation',
|
||||
'end',
|
||||
'fill',
|
||||
'fill-opacity',
|
||||
'fill-rule',
|
||||
'filter',
|
||||
'filterunits',
|
||||
'flood-color',
|
||||
'flood-opacity',
|
||||
'font-family',
|
||||
'font-size',
|
||||
'font-size-adjust',
|
||||
'font-stretch',
|
||||
'font-style',
|
||||
'font-variant',
|
||||
'font-weight',
|
||||
'fx',
|
||||
'fy',
|
||||
'g1',
|
||||
'g2',
|
||||
'glyph-name',
|
||||
'glyphref',
|
||||
'gradientunits',
|
||||
'gradienttransform',
|
||||
'height',
|
||||
'href',
|
||||
'id',
|
||||
'image-rendering',
|
||||
'in',
|
||||
'in2',
|
||||
'k',
|
||||
'k1',
|
||||
'k2',
|
||||
'k3',
|
||||
'k4',
|
||||
'kerning',
|
||||
'keypoints',
|
||||
'keysplines',
|
||||
'keytimes',
|
||||
'lang',
|
||||
'lengthadjust',
|
||||
'letter-spacing',
|
||||
'kernelmatrix',
|
||||
'kernelunitlength',
|
||||
'lighting-color',
|
||||
'local',
|
||||
'marker-end',
|
||||
'marker-mid',
|
||||
'marker-start',
|
||||
'markerheight',
|
||||
'markerunits',
|
||||
'markerwidth',
|
||||
'maskcontentunits',
|
||||
'maskunits',
|
||||
'max',
|
||||
'mask',
|
||||
'media',
|
||||
'method',
|
||||
'mode',
|
||||
'min',
|
||||
'name',
|
||||
'numoctaves',
|
||||
'offset',
|
||||
'operator',
|
||||
'opacity',
|
||||
'order',
|
||||
'orient',
|
||||
'orientation',
|
||||
'origin',
|
||||
'overflow',
|
||||
'paint-order',
|
||||
'path',
|
||||
'pathlength',
|
||||
'patterncontentunits',
|
||||
'patterntransform',
|
||||
'patternunits',
|
||||
'points',
|
||||
'preservealpha',
|
||||
'preserveaspectratio',
|
||||
'primitiveunits',
|
||||
'r',
|
||||
'rx',
|
||||
'ry',
|
||||
'radius',
|
||||
'refx',
|
||||
'refy',
|
||||
'repeatcount',
|
||||
'repeatdur',
|
||||
'restart',
|
||||
'result',
|
||||
'rotate',
|
||||
'scale',
|
||||
'seed',
|
||||
'shape-rendering',
|
||||
'specularconstant',
|
||||
'specularexponent',
|
||||
'spreadmethod',
|
||||
'startoffset',
|
||||
'stddeviation',
|
||||
'stitchtiles',
|
||||
'stop-color',
|
||||
'stop-opacity',
|
||||
'stroke-dasharray',
|
||||
'stroke-dashoffset',
|
||||
'stroke-linecap',
|
||||
'stroke-linejoin',
|
||||
'stroke-miterlimit',
|
||||
'stroke-opacity',
|
||||
'stroke',
|
||||
'stroke-width',
|
||||
'style',
|
||||
'surfacescale',
|
||||
'tabindex',
|
||||
'targetx',
|
||||
'targety',
|
||||
'transform',
|
||||
'text-anchor',
|
||||
'text-decoration',
|
||||
'text-rendering',
|
||||
'textlength',
|
||||
'type',
|
||||
'u1',
|
||||
'u2',
|
||||
'unicode',
|
||||
'values',
|
||||
'viewbox',
|
||||
'visibility',
|
||||
'version',
|
||||
'vert-adv-y',
|
||||
'vert-origin-x',
|
||||
'vert-origin-y',
|
||||
'width',
|
||||
'word-spacing',
|
||||
'wrap',
|
||||
'writing-mode',
|
||||
'xchannelselector',
|
||||
'ychannelselector',
|
||||
'x',
|
||||
'x1',
|
||||
'x2',
|
||||
'xmlns',
|
||||
'y',
|
||||
'y1',
|
||||
'y2',
|
||||
'z',
|
||||
'zoomandpan',
|
||||
];
|
||||
|
||||
export const mathMl = [
|
||||
'accent',
|
||||
'accentunder',
|
||||
'align',
|
||||
'bevelled',
|
||||
'close',
|
||||
'columnsalign',
|
||||
'columnlines',
|
||||
'columnspan',
|
||||
'denomalign',
|
||||
'depth',
|
||||
'dir',
|
||||
'display',
|
||||
'displaystyle',
|
||||
'encoding',
|
||||
'fence',
|
||||
'frame',
|
||||
'height',
|
||||
'href',
|
||||
'id',
|
||||
'largeop',
|
||||
'length',
|
||||
'linethickness',
|
||||
'lspace',
|
||||
'lquote',
|
||||
'mathbackground',
|
||||
'mathcolor',
|
||||
'mathsize',
|
||||
'mathvariant',
|
||||
'maxsize',
|
||||
'minsize',
|
||||
'movablelimits',
|
||||
'notation',
|
||||
'numalign',
|
||||
'open',
|
||||
'rowalign',
|
||||
'rowlines',
|
||||
'rowspacing',
|
||||
'rowspan',
|
||||
'rspace',
|
||||
'rquote',
|
||||
'scriptlevel',
|
||||
'scriptminsize',
|
||||
'scriptsizemultiplier',
|
||||
'selection',
|
||||
'separator',
|
||||
'separators',
|
||||
'stretchy',
|
||||
'subscriptshift',
|
||||
'supscriptshift',
|
||||
'symmetric',
|
||||
'voffset',
|
||||
'width',
|
||||
'xmlns',
|
||||
];
|
||||
|
||||
export const xml = [
|
||||
'xlink:href',
|
||||
'xml:id',
|
||||
'xlink:title',
|
||||
'xml:space',
|
||||
'xmlns:xlink',
|
||||
];
|
||||
|
||||
/**
|
||||
* Anything in here will be supported as HTML attributes from TechDocs.
|
||||
*
|
||||
* @note Be conscious about what you add here. It can affect the TechDocs experience. For
|
||||
* some review before making a PR, reach out in the #docs-like-code channel in Discord first.
|
||||
*/
|
||||
export const TECHDOCS_ALLOWED_ATTRIBUTES = {
|
||||
'*': [...html, ...svg, ...mathMl, ...xml, 'data-*'],
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { createTestShadowDom, FIXTURES } from '../../../test-utils';
|
||||
import { Transformer } from '../index';
|
||||
import { sanitizeDOM } from '../sanitizeDOM';
|
||||
|
||||
const injectMaliciousLink = (): Transformer => dom => {
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('id', 'test-malicious-link');
|
||||
link.setAttribute('onclick', 'alert("Hello world");');
|
||||
dom.querySelector('body')?.appendChild(link);
|
||||
return dom;
|
||||
};
|
||||
|
||||
describe('sanitizeDOM', () => {
|
||||
it('contains a script tag', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
|
||||
|
||||
expect(shadowDom.querySelectorAll('script').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not contain a script tag', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelectorAll('script').length).toBe(0);
|
||||
});
|
||||
|
||||
it('contains link with a onClick attribute', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [injectMaliciousLink()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not contain link with a onClick attribute', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
shadowDom.querySelector('#test-malicious-link')?.hasAttribute('onclick'),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('removes style tags', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<style>* {color: #f0f;}<style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const shadowDom = createTestShadowDom(html, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelectorAll('style').length).toEqual(0);
|
||||
});
|
||||
|
||||
it('does not remove link tags', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const shadowDom = createTestShadowDom(html, {
|
||||
preTransformers: [sanitizeDOM()],
|
||||
postTransformers: [],
|
||||
});
|
||||
|
||||
expect(shadowDom.querySelectorAll('link').length).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Transformer } from '../transformer';
|
||||
import { TECHDOCS_ALLOWED_TAGS } from './tags';
|
||||
import { TECHDOCS_ALLOWED_ATTRIBUTES } from './attributes';
|
||||
|
||||
// TODO(freben): move all of this out of index
|
||||
|
||||
export const sanitizeDOM = (): Transformer => {
|
||||
return dom => {
|
||||
const sanitizedHtml = sanitizeHtml(dom.innerHTML, {
|
||||
allowedTags: TECHDOCS_ALLOWED_TAGS,
|
||||
allowedAttributes: TECHDOCS_ALLOWED_ATTRIBUTES,
|
||||
allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'data', 'blob'],
|
||||
});
|
||||
|
||||
return new DOMParser().parseFromString(sanitizedHtml, 'text/html')
|
||||
.documentElement;
|
||||
};
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This is the source of truth for what HTML tags we support in TechDocs.
|
||||
*/
|
||||
|
||||
// prettier-ignore
|
||||
export const html = [
|
||||
'a', 'abbr', 'acronym',
|
||||
'address', 'area', 'article',
|
||||
'aside', 'audio', 'b', 'bdi',
|
||||
'bdo', 'big',
|
||||
'blink', 'blockquote',
|
||||
'body',
|
||||
'br', 'button',
|
||||
'canvas', 'caption', 'center', 'cite', 'code',
|
||||
'col', 'colgroup',
|
||||
'content',
|
||||
'data',
|
||||
'datalist',
|
||||
'dd',
|
||||
'decorator',
|
||||
'del',
|
||||
'details',
|
||||
'dfn',
|
||||
'dir',
|
||||
'div',
|
||||
'dl',
|
||||
'dt',
|
||||
'element',
|
||||
'em',
|
||||
'fieldset',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'font',
|
||||
'footer',
|
||||
'form',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'head',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'html',
|
||||
'i',
|
||||
'img',
|
||||
'input',
|
||||
'ins',
|
||||
'kbd',
|
||||
'label',
|
||||
'legend',
|
||||
'li',
|
||||
'link',
|
||||
'main',
|
||||
'map',
|
||||
'mark',
|
||||
'marquee',
|
||||
'menu',
|
||||
'menuitem',
|
||||
'meter',
|
||||
'nav',
|
||||
'nobr',
|
||||
'ol',
|
||||
'optgroup',
|
||||
'option',
|
||||
'output',
|
||||
'p',
|
||||
'picture',
|
||||
'pre',
|
||||
'progress',
|
||||
'q',
|
||||
'rp',
|
||||
'rt',
|
||||
'ruby',
|
||||
's',
|
||||
'samp',
|
||||
'section',
|
||||
'select',
|
||||
'shadow',
|
||||
'small',
|
||||
'source',
|
||||
'spacer',
|
||||
'span',
|
||||
'strike',
|
||||
'strong',
|
||||
'sub',
|
||||
'summary',
|
||||
'sup',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'template',
|
||||
'textarea',
|
||||
'tfoot',
|
||||
'th',
|
||||
'thead',
|
||||
'time',
|
||||
'tr',
|
||||
'track',
|
||||
'tt',
|
||||
'u',
|
||||
'ul',
|
||||
'var',
|
||||
'video',
|
||||
'wbr',
|
||||
];
|
||||
|
||||
// SVG
|
||||
export const svg = [
|
||||
'svg',
|
||||
'a',
|
||||
'altglyph',
|
||||
'altglyphdef',
|
||||
'altglyphitem',
|
||||
'animatecolor',
|
||||
'animatemotion',
|
||||
'animatetransform',
|
||||
'audio',
|
||||
'canvas',
|
||||
'circle',
|
||||
'clippath',
|
||||
'defs',
|
||||
'desc',
|
||||
'ellipse',
|
||||
'filter',
|
||||
'font',
|
||||
'g',
|
||||
'glyph',
|
||||
'glyphref',
|
||||
'hkern',
|
||||
'image',
|
||||
'line',
|
||||
'lineargradient',
|
||||
'marker',
|
||||
'mask',
|
||||
'metadata',
|
||||
'mpath',
|
||||
'path',
|
||||
'pattern',
|
||||
'polygon',
|
||||
'polyline',
|
||||
'radialgradient',
|
||||
'rect',
|
||||
'stop',
|
||||
'switch',
|
||||
'symbol',
|
||||
'text',
|
||||
'textpath',
|
||||
'title',
|
||||
'tref',
|
||||
'tspan',
|
||||
'video',
|
||||
'view',
|
||||
'vkern',
|
||||
];
|
||||
|
||||
export const svgFilters = [
|
||||
'feBlend',
|
||||
'feColorMatrix',
|
||||
'feComponentTransfer',
|
||||
'feComposite',
|
||||
'feConvolveMatrix',
|
||||
'feDiffuseLighting',
|
||||
'feDisplacementMap',
|
||||
'feDistantLight',
|
||||
'feFlood',
|
||||
'feFuncA',
|
||||
'feFuncB',
|
||||
'feFuncG',
|
||||
'feFuncR',
|
||||
'feGaussianBlur',
|
||||
'feMerge',
|
||||
'feMergeNode',
|
||||
'feMorphology',
|
||||
'feOffset',
|
||||
'fePointLight',
|
||||
'feSpecularLighting',
|
||||
'feSpotLight',
|
||||
'feTile',
|
||||
'feTurbulence',
|
||||
];
|
||||
|
||||
export const mathMl = [
|
||||
'math',
|
||||
'menclose',
|
||||
'merror',
|
||||
'mfenced',
|
||||
'mfrac',
|
||||
'mglyph',
|
||||
'mi',
|
||||
'mlabeledtr',
|
||||
'mmultiscripts',
|
||||
'mn',
|
||||
'mo',
|
||||
'mover',
|
||||
'mpadded',
|
||||
'mphantom',
|
||||
'mroot',
|
||||
'mrow',
|
||||
'ms',
|
||||
'mspace',
|
||||
'msqrt',
|
||||
'mstyle',
|
||||
'msub',
|
||||
'msup',
|
||||
'msubsup',
|
||||
'mtable',
|
||||
'mtd',
|
||||
'mtext',
|
||||
'mtr',
|
||||
'munder',
|
||||
'munderover',
|
||||
];
|
||||
|
||||
export const text = ['#text'];
|
||||
|
||||
/**
|
||||
* Anything in here will be executed as HTML tags from TechDocs.
|
||||
*
|
||||
* @note Be conscious about what you add here. It can affect the TechDocs experience. For
|
||||
* some review before making a PR, reach out in the #docs-like-code channel in Discord first.
|
||||
*/
|
||||
export const TECHDOCS_ALLOWED_TAGS = [
|
||||
...html,
|
||||
...svg,
|
||||
...svgFilters,
|
||||
...mathMl,
|
||||
...text,
|
||||
'iframe',
|
||||
];
|
||||
@@ -18,20 +18,26 @@ import { createTestShadowDom, FIXTURES } from '../../test-utils';
|
||||
import { simplifyMkdocsFooter } from './simplifyMkdocsFooter';
|
||||
|
||||
describe('simplifyMkdocsFooter', () => {
|
||||
it('does not remove mkdocs copyright', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
});
|
||||
it('does not remove mkdocs copyright', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelector('.md-footer-copyright')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does remove mkdocs copyright', () => {
|
||||
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
|
||||
preTransformers: [simplifyMkdocsFooter()],
|
||||
postTransformers: [],
|
||||
});
|
||||
it('does remove mkdocs copyright', async () => {
|
||||
const shadowDom = await createTestShadowDom(
|
||||
FIXTURES.FIXTURE_STANDARD_PAGE,
|
||||
{
|
||||
preTransformers: [simplifyMkdocsFooter()],
|
||||
postTransformers: [],
|
||||
},
|
||||
);
|
||||
|
||||
expect(shadowDom.querySelector('.md-footer-copyright')).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type Transformer = (dom: Element) => Element;
|
||||
export type Transformer = (dom: Element) => Element | Promise<Element>;
|
||||
|
||||
export const transform = (
|
||||
export const transform = async (
|
||||
html: string | Element,
|
||||
transformers: Transformer[],
|
||||
): Element => {
|
||||
): Promise<Element> => {
|
||||
let dom: Element;
|
||||
|
||||
if (typeof html === 'string') {
|
||||
@@ -30,9 +30,9 @@ export const transform = (
|
||||
throw new Error('dom is not a recognized type');
|
||||
}
|
||||
|
||||
transformers.forEach(transformer => {
|
||||
dom = transformer(dom);
|
||||
});
|
||||
for (const transformer of transformers) {
|
||||
dom = await transformer(dom);
|
||||
}
|
||||
|
||||
return dom;
|
||||
};
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '',
|
||||
id: 'techdocs-index-page',
|
||||
title: 'TechDocs Landing Page',
|
||||
});
|
||||
|
||||
export const rootDocsRouteRef = createRouteRef({
|
||||
path: ':namespace/:kind/:name/*',
|
||||
id: 'techdocs-reader-page',
|
||||
title: 'Docs',
|
||||
params: ['namespace', 'kind', 'name'],
|
||||
});
|
||||
|
||||
export const rootCatalogDocsRouteRef = createRouteRef({
|
||||
path: '*',
|
||||
id: 'catalog-techdocs-reader-view',
|
||||
title: 'Docs',
|
||||
});
|
||||
|
||||
@@ -22,13 +22,13 @@ export type CreateTestShadowDomOptions = {
|
||||
postTransformers: Transformer[];
|
||||
};
|
||||
|
||||
export const createTestShadowDom = (
|
||||
export const createTestShadowDom = async (
|
||||
fixture: string,
|
||||
opts: CreateTestShadowDomOptions = {
|
||||
preTransformers: [],
|
||||
postTransformers: [],
|
||||
},
|
||||
): ShadowRoot => {
|
||||
): Promise<ShadowRoot> => {
|
||||
const divElement = document.createElement('div');
|
||||
divElement.attachShadow({ mode: 'open' });
|
||||
document.body.appendChild(divElement);
|
||||
@@ -39,7 +39,7 @@ export const createTestShadowDom = (
|
||||
'text/html',
|
||||
).documentElement;
|
||||
if (opts.preTransformers) {
|
||||
dom = transformer(dom, opts.preTransformers);
|
||||
dom = await transformer(dom, opts.preTransformers);
|
||||
}
|
||||
|
||||
// Mount the UI
|
||||
@@ -47,7 +47,7 @@ export const createTestShadowDom = (
|
||||
|
||||
// Transformers after the UI is rendered
|
||||
if (opts.postTransformers) {
|
||||
transformer(dom, opts.postTransformers);
|
||||
await transformer(dom, opts.postTransformers);
|
||||
}
|
||||
|
||||
return divElement.shadowRoot!;
|
||||
|
||||
Reference in New Issue
Block a user