Merge branch 'master' into feature/techdocs-e2e

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-09-28 11:12:31 +02:00
1927 changed files with 64963 additions and 24801 deletions
+170
View File
@@ -1,5 +1,175 @@
# @backstage/plugin-techdocs
## 0.11.3
### Patch Changes
- be13dfe61a: Make techdocs context search bar width adjust on smaller screens.
- Updated dependencies
- @backstage/core-components@0.5.0
- @backstage/integration@0.6.5
- @backstage/plugin-catalog@0.6.16
- @backstage/plugin-catalog-react@0.5.0
- @backstage/catalog-model@0.9.3
- @backstage/config@0.1.10
- @backstage/integration-react@0.1.10
- @backstage/plugin-search@0.4.12
## 0.11.2
### Patch Changes
- 1d346ba903: Modify TechDocsCollator to be aware of new TechDocs URL pattern. Modify tech docs in context search to use correct casing when creating initial filter.
- 9f1362dcc1: Upgrade `@material-ui/lab` to `4.0.0-alpha.57`.
- 96fef17a18: Upgrade git-parse-url to v11.6.0
- Updated dependencies
- @backstage/core-components@0.4.2
- @backstage/integration@0.6.4
- @backstage/integration-react@0.1.9
- @backstage/plugin-catalog@0.6.15
- @backstage/plugin-catalog-react@0.4.6
- @backstage/plugin-search@0.4.11
- @backstage/core-plugin-api@0.1.8
## 0.11.1
### Patch Changes
- 30ed662a3: Adding in-context search to TechDocs Reader component. Using existing search-backend to query for indexed search results scoped into a specific entity's techdocs. Needs TechDocsCollator enabled on the backend to work.
Adding extra information to indexed tech docs documents for search.
- 434dfc5d4: Display [metadata.title](https://backstage.io/docs/features/software-catalog/descriptor-format#title-optional) for components on the TechDocs homepage, if defined; otherwise fall back to `metadata.name` as displayed before.
- Updated dependencies
- @backstage/plugin-catalog-react@0.4.5
- @backstage/integration@0.6.3
- @backstage/core-components@0.4.0
- @backstage/plugin-catalog@0.6.14
- @backstage/plugin-search@0.4.9
- @backstage/catalog-model@0.9.1
- @backstage/integration-react@0.1.8
## 0.11.0
### Minor Changes
- c772d9a84: TechDocs sites can now be accessed using paths containing entity triplets of
any case (e.g. `/docs/namespace/KIND/name` or `/docs/namespace/kind/name`).
If you do not use an external storage provider for serving TechDocs, this is a
transparent change and no action is required from you.
If you _do_ use an external storage provider for serving TechDocs (one of\* GCS,
AWS S3, or Azure Blob Storage), you must run a migration command against your
storage provider before updating.
[A migration guide is available here](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta).
- (\*) We're seeking help from the community to bring OpenStack Swift support
[to feature parity](https://github.com/backstage/backstage/issues/6763) with the above.
- 787bc0826: The TechDocs plugin has completed the migration to the Composability API. In
order to update to this version, please ensure you've made all necessary
changes to your `App.tsx` file as outlined in the [create-app changelog][cacl].
[cacl]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md
### Patch Changes
- 90c68a2ca: Fix Techdocs feedback icon link for GitHub URLs
- Updated dependencies
- @backstage/plugin-catalog@0.6.13
- @backstage/plugin-catalog-react@0.4.4
- @backstage/core-components@0.3.3
- @backstage/integration@0.6.2
- @backstage/config@0.1.8
## 0.10.4
### Patch Changes
- a440d3b38: Expose a new composable `TechDocsIndexPage` and a `DefaultTechDocsHome` with support for starring docs and filtering on owned, starred, owner, and tags.
You can migrate to the new UI view by making the following changes in your `App.tsx`:
```diff
- <Route path="/docs" element={<TechdocsPage />} />
+ <Route path="/docs" element={<TechDocsIndexPage />}>
+ <DefaultTechDocsHome />
+ </Route>
+ <Route
+ path="/docs/:namespace/:kind/:name/*"
+ element={<TechDocsReaderPage />}
+ />
```
- 56c773909: Switched `@types/react` dependency to request `*` rather than a specific version.
- 8a3e46591: Switch `EventSource` implementation with header support from a Node.js API-based one to an XHR-based one.
- Updated dependencies
- @backstage/integration@0.6.0
- @backstage/core-components@0.3.1
- @backstage/core-plugin-api@0.1.6
- @backstage/plugin-catalog@0.6.11
- @backstage/plugin-catalog-react@0.4.2
- @backstage/integration-react@0.1.7
## 0.10.3
### Patch Changes
- 260c053b9: Fix All Material UI Warnings
- db58cf06c: Avoid sanitize safe links in the header of document pages.
- 1d65bd490: Fix Techdocs feedback icon link for GitLab URLs with subgroup(s) in path
- Updated dependencies
- @backstage/core-components@0.3.0
- @backstage/config@0.1.6
- @backstage/core-plugin-api@0.1.5
- @backstage/integration@0.5.9
- @backstage/integration-react@0.1.6
- @backstage/plugin-catalog-react@0.4.1
## 0.10.2
### Patch Changes
- 9d40fcb1e: - Bumping `material-ui/core` version to at least `4.12.2` as they made some breaking changes in later versions which broke `Pagination` of the `Table`.
- Switching out `material-table` to `@material-table/core` for support for the later versions of `material-ui/core`
- This causes a minor API change to `@backstage/core-components` as the interface for `Table` re-exports the `prop` from the underlying `Table` components.
- `onChangeRowsPerPage` has been renamed to `onRowsPerPageChange`
- `onChangePage` has been renamed to `onPageChange`
- Migration guide is here: https://material-table-core.com/docs/breaking-changes
- 11c370af2: Optimize load times by only fetching entities with the `backstage.io/techdocs-ref` annotation
- 2b1ac002d: TechDocs now uses a "safe by default" sanitization library, rather than relying on its own, hard-coded list of allowable tags and attributes.
- Updated dependencies
- @backstage/core-components@0.2.0
- @backstage/plugin-catalog-react@0.4.0
- @backstage/core-plugin-api@0.1.4
- @backstage/integration-react@0.1.5
- @backstage/theme@0.2.9
## 0.10.1
### Patch Changes
- 9266b80ab: Add search list item to display tech docs search results
- 03bf17e9b: Improve the responsiveness of the EntityPage UI. With this the Header component should scale with the screen size & wrapping should not cause overflowing/blocking of links. Additionally enforce the Pages using the Grid Layout to use it across all screen sizes & to wrap as intended.
To benefit from the improved responsive layout, the `EntityPage` in existing Backstage applications should be updated to set the `xs` column size on each grid item in the page, as this does not default. For example:
```diff
- <Grid item md={6}>
+ <Grid item xs={12} md={6}>
```
- 378cc6a54: Only update the `path` when the content is updated.
If content and path are updated independently, the frontend rendering is triggered twice on each navigation: Once for the `path` change (with the old content) and once for the new content.
This might result in a flickering rendering that is caused by the async frontend preprocessing, and the fact that replacing the shadow dom content is expensive.
- 214e7c52d: Refactor the techdocs transformers to return `Promise`s and await all transformations.
- e35b13afa: Handle error responses in `getTechDocsMetadata` and `getEntityMetadata` such that `<TechDocsPageHeader>` doesn't throw errors.
- Updated dependencies
- @backstage/core-components@0.1.6
- @backstage/plugin-catalog-react@0.3.1
## 0.10.0
### Minor Changes
+4 -4
View File
@@ -2,12 +2,12 @@
## Getting started
Set up Backstage and TechDocs by follow our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
Set up Backstage and TechDocs by following our guide on [Getting Started](../../docs/features/techdocs/getting-started.md).
## Configuration
http://backstage.io/docs/features/techdocs/configuration
Refer to our [configuration reference](../../docs/features/techdocs/configuration.md) for a complete listing of configuration options.
### TechDocs Storage Api
### TechDocs Storage API
The default setup of TechDocs assumes your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. If for some reason you want to change this it can be configured by implementing a new techdocs storage API. Do this by implementing TechDocsStorage found in `plugins/techdocs/src/api`. Add your new API to the application in `app/src/apis.ts` (or replace if it's already registered as an API).
The default setup of TechDocs assumes that your documentation is accessed by reading a page with the format of `<storageUrl>/<entity kind>/<entity namespace>/<entity name>`. This can be configured by [implementing a new techdocs storage API](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-implement-your-own-techdocs-apis).
+153 -5
View File
@@ -5,6 +5,7 @@
```ts
/// <reference types="react" />
import { Action } from '@material-table/core';
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
@@ -14,7 +15,65 @@ import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { IdentityApi } from '@backstage/core-plugin-api';
import { LocationSpec } from '@backstage/catalog-model';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { TableColumn } from '@backstage/core-components';
import { TableProps } from '@backstage/core-components';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// Warning: (ae-missing-release-tag) "createCopyDocsUrlAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createCopyDocsUrlAction(copyToClipboard: Function): (
row: DocsTableRow,
) => {
icon: () => JSX.Element;
tooltip: string;
onClick: () => any;
};
// Warning: (ae-missing-release-tag) "createNameColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createNameColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "createOwnerColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createOwnerColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "createStarEntityAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createStarEntityAction(
isStarredEntity: Function,
toggleStarredEntity: Function,
): ({ entity }: DocsTableRow) => {
cellStyle: {
paddingLeft: string;
};
icon: () => JSX.Element;
tooltip: string;
onClick: () => any;
};
// Warning: (ae-missing-release-tag) "createTypeColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createTypeColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "DefaultTechDocsHome" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DefaultTechDocsHome: ({
initialFilter,
columns,
actions,
}: {
initialFilter?: UserListFilterKind | undefined;
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?: TableProps<DocsTableRow>['actions'];
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsCardGrid" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -25,22 +84,81 @@ export const DocsCardGrid: ({
entities: Entity[] | undefined;
}) => JSX.Element | null;
// Warning: (ae-missing-release-tag) "DocsResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DocsResultListItem: ({
result,
lineClamp,
asListItem,
asLink,
title,
}: {
result: any;
lineClamp?: number | undefined;
asListItem?: boolean | undefined;
asLink?: boolean | undefined;
title?: string | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DocsTable: ({
entities,
title,
loading,
columns,
actions,
}: {
entities: Entity[] | undefined;
title?: string | undefined;
loading?: boolean | undefined;
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?:
| (
| Action<DocsTableRow>
| {
action: (rowData: DocsTableRow) => Action<DocsTableRow>;
position: string;
}
| ((rowData: DocsTableRow) => Action<DocsTableRow>)
)[]
| undefined;
}) => JSX.Element | null;
// Warning: (ae-missing-release-tag) "DocsTableRow" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type DocsTableRow = {
entity: Entity;
resolved: {
docsUrl: string;
ownedByRelationsTitle: string;
ownedByRelations: EntityName[];
};
};
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "EmbeddedDocsRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EmbeddedDocsRouter: (_props: Props) => JSX.Element;
export const EmbeddedDocsRouter: (_props: Props_2) => JSX.Element;
// Warning: (ae-missing-release-tag) "EntityListDocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityListDocsTable: {
({
columns,
actions,
}: {
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?: TableProps<DocsTableRow>['actions'];
}): JSX.Element;
columns: typeof columnFactories;
actions: typeof actionFactories;
};
// Warning: (ae-missing-release-tag) "EntityTechdocsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -49,6 +167,11 @@ export const EntityTechdocsContent: (_props: {
entity?: Entity | undefined;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "isTechDocsAvailable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isTechDocsAvailable: (entity: Entity) => boolean;
// Warning: (ae-missing-release-tag) "PanelType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -58,7 +181,11 @@ export type PanelType = 'DocsCardGrid' | 'DocsTable';
// Warning: (ae-missing-release-tag) "Reader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const Reader: ({ entityId, onReady }: Props_2) => JSX.Element;
export const Reader: ({
entityId,
onReady,
withSearch,
}: Props_3) => JSX.Element;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -132,17 +259,38 @@ export const TechDocsCustomHome: ({
tabsConfig: TabsConfig;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsIndexPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsIndexPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "TechdocsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechdocsPage: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "TechDocsPageWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPageWrapper: ({ children }: Props) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPicker: () => null;
// Warning: (ae-missing-release-tag) "techdocsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
const techdocsPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
docRoot: RouteRef<{
name: string;
kind: string;
namespace: string;
}>;
entityContent: RouteRef<undefined>;
},
{}
@@ -244,7 +392,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
// Warnings were encountered during analysis:
//
// src/plugin.d.ts:18:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
// src/home/components/EntityListDocsTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
// src/home/components/EntityListDocsTable.d.ts:12:5 - (ae-forgotten-export) The symbol "actionFactories" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:29:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
```
+7
View File
@@ -26,6 +26,13 @@ export interface Config {
*/
builder: 'local' | 'external';
/**
* Allows fallback to case-sensitive triplets in case of migration issues.
* @visibility frontend
* @see https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta
*/
legacyUseCaseSensitiveTripletPaths?: boolean;
/**
* @example http://localhost:7000/api/techdocs
* @visibility frontend
+26 -20
View File
@@ -1,6 +1,7 @@
{
"name": "@backstage/plugin-techdocs",
"version": "0.10.0",
"description": "The Backstage plugin that renders technical documentation for your components",
"version": "0.11.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,41 +32,46 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.5",
"@backstage/core-components": "^0.1.5",
"@backstage/core-plugin-api": "^0.1.3",
"@backstage/catalog-model": "^0.9.3",
"@backstage/config": "^0.1.10",
"@backstage/core-components": "^0.5.0",
"@backstage/core-plugin-api": "^0.1.8",
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.8",
"@backstage/integration-react": "^0.1.4",
"@backstage/plugin-catalog-react": "^0.3.0",
"@backstage/theme": "^0.2.8",
"@material-ui/core": "^4.11.0",
"@backstage/integration": "^0.6.5",
"@backstage/integration-react": "^0.1.10",
"@backstage/plugin-catalog": "^0.6.16",
"@backstage/plugin-catalog-react": "^0.5.0",
"@backstage/plugin-search": "^0.4.12",
"@backstage/theme": "^0.2.10",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/styles": "^4.10.0",
"eventsource": "^1.1.0",
"@types/react": "*",
"dompurify": "^2.2.9",
"event-source-polyfill": "^1.0.25",
"git-url-parse": "^11.6.0",
"lodash": "^4.17.21",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4",
"sanitize-html": "^2.3.2"
"react-text-truncate": "^0.16.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/core-app-api": "^0.1.4",
"@backstage/dev-utils": "^0.2.2",
"@backstage/test-utils": "^0.1.14",
"@backstage/cli": "^0.7.13",
"@backstage/core-app-api": "^0.1.14",
"@backstage/dev-utils": "^0.2.10",
"@backstage/test-utils": "^0.1.17",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^13.1.8",
"@types/eventsource": "^1.1.5",
"@types/dompurify": "^2.2.2",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/react": "^16.9",
"canvas": "^2.6.1",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
+1
View File
@@ -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',
+11 -13
View File
@@ -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>
);
};
+15 -19
View File
@@ -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);
+3 -2
View File
@@ -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
View File
@@ -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;
@@ -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
@@ -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[];
};
};
+16 -2
View File
@@ -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';
+11
View File
@@ -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 () => {
+197 -135
View File
@@ -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;
};
+4 -3
View File
@@ -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',
});
+4 -4
View File
@@ -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!;