Moved shared TechDocs utilities from plugin-techdocs to plugin-techdocs-react

Co-authored-by: Eric Peterson <iamEAP@users.noreply.github.com>
Co-authored-by: Anders Näsman <realandersn@users.noreply.github.com>
Signed-off-by: Otto Sichert <git@ottosichert.de>
This commit is contained in:
Otto Sichert
2022-04-11 17:06:32 +02:00
parent b05870bcb7
commit 883ab7228a
20 changed files with 352 additions and 209 deletions
+62
View File
@@ -3,15 +3,25 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AsyncState } from 'react-use/lib/useAsync';
import { ComponentType } from 'react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Context } from 'react';
import { Dispatch } from 'react';
import { Entity } from '@backstage/catalog-model';
import { Extension } from '@backstage/core-plugin-api';
import { default as React_2 } from 'react';
import { SetStateAction } from 'react';
import { VersionedValue } from '@backstage/version-bridge';
// @alpha
export function createTechDocsAddon<TComponentProps>(
options: TechDocsAddonOptions<TComponentProps>,
): Extension<ComponentType<TComponentProps>>;
// @alpha (undocumented)
export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue;
// @alpha
export const TECHDOCS_ADDONS_WRAPPER_KEY = 'techdocs.addons.wrapper.v1';
@@ -34,6 +44,55 @@ export type TechDocsAddonOptions<TAddonProps = {}> = {
// @alpha
export const TechDocsAddons: React_2.ComponentType;
// @public
export type TechDocsEntityMetadata = Entity & {
locationMetadata?: {
type: string;
target: string;
};
};
// @public
export type TechDocsMetadata = {
site_name: string;
site_description: string;
};
// @alpha (undocumented)
export const TechDocsReaderPageContext: Context<
| VersionedValue<{
1: TechDocsReaderPageValue;
}>
| undefined
>;
// @alpha
export type TechDocsReaderPageValue = {
metadata: AsyncState<TechDocsMetadata>;
entityName: CompoundEntityRef;
entityMetadata: AsyncState<TechDocsEntityMetadata>;
shadowRoot?: ShadowRoot;
setShadowRoot: Dispatch<SetStateAction<ShadowRoot | undefined>>;
title: string;
setTitle: Dispatch<SetStateAction<string>>;
subtitle: string;
setSubtitle: Dispatch<SetStateAction<string>>;
onReady?: () => void;
};
// @public
export const useShadowRoot: () => ShadowRoot | undefined;
// @public
export const useShadowRootElements: <
TReturnedElement extends HTMLElement = HTMLElement,
>(
selectors: string[],
) => TReturnedElement[];
// @public
export const useShadowRootSelection: (wait?: number) => Selection | null;
// @alpha
export const useTechDocsAddons: () => {
renderComponentByName: (name: string) => React_2.ReactElement<
@@ -51,4 +110,7 @@ export const useTechDocsAddons: () => {
> | null)[]
| null;
};
// @alpha
export const useTechDocsReaderPage: () => TechDocsReaderPageValue;
```
+8 -2
View File
@@ -37,20 +37,26 @@
"@backstage/catalog-model": "^1.0.1-next.1",
"@backstage/core-components": "^0.9.3-next.1",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/version-bridge": "^1.0.0",
"@material-ui/core": "^4.12.2",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/styles": "^4.11.0",
"jss": "~10.8.2",
"lodash": "^4.17.21",
"react-helmet": "6.1.0",
"react-router-dom": "6.0.0-beta.0"
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.3.2"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@testing-library/react": "^13.0.0",
"@testing-library/react-hooks": "^7.0.2",
"@backstage/test-utils": "^1.0.1-next.1"
"@backstage/plugin-techdocs": "^1.0.1-next.2",
"@backstage/test-utils": "^1.0.1-next.1",
"@backstage/theme": "^0.2.15"
},
"files": [
"dist"
+129
View File
@@ -0,0 +1,129 @@
/*
* Copyright 2022 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 { renderHook, act } from '@testing-library/react-hooks';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { TestApiProvider } from '@backstage/test-utils';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import {
techdocsApiRef,
TechDocsReaderPageProvider,
} from '@backstage/plugin-techdocs';
import { useTechDocsReaderPage } from './context';
import { TechDocsMetadata } from './types';
const mockShadowRoot = () => {
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '<h1>Shadow DOM Mock</h1>';
return shadowRoot;
};
const mockEntityMetadata: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
namespace: 'default',
},
spec: {
owner: 'test',
},
};
const mockTechDocsMetadata: TechDocsMetadata = {
site_name: 'test-componnet',
site_description: 'this is a test component',
};
const techdocsApiMock = {
getEntityMetadata: jest.fn().mockResolvedValue(mockEntityMetadata),
getTechDocsMetadata: jest.fn().mockResolvedValue(mockTechDocsMetadata),
};
const wrapper = ({
entityName = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityName?: CompoundEntityRef;
children: React.ReactNode;
}) => (
<ThemeProvider theme={lightTheme}>
<TestApiProvider apis={[[techdocsApiRef, techdocsApiMock]]}>
<TechDocsReaderPageProvider entityName={entityName}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
</ThemeProvider>
);
describe('useTechDocsReaderPage', () => {
it('should set title', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useTechDocsReaderPage(),
{ wrapper },
);
expect(result.current.title).toBe('');
act(() => result.current.setTitle('test site title'));
await waitForNextUpdate();
expect(result.current.title).toBe('test site title');
});
it('should set subtitle', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useTechDocsReaderPage(),
{ wrapper },
);
expect(result.current.subtitle).toBe('');
act(() => result.current.setSubtitle('test site subtitle'));
await waitForNextUpdate();
expect(result.current.subtitle).toBe('test site subtitle');
});
it('should set shadow root', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useTechDocsReaderPage(),
{ wrapper },
);
// mock shadowroot
const shadowRoot = mockShadowRoot();
act(() => result.current.setShadowRoot(shadowRoot));
await waitForNextUpdate();
expect(result.current.shadowRoot?.innerHTML).toBe(
'<h1>Shadow DOM Mock</h1>',
);
});
});
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright 2022 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 { Dispatch, SetStateAction, useContext } from 'react';
import { AsyncState } from 'react-use/lib/useAsync';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { createVersionedContext } from '@backstage/version-bridge';
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
/**
* @alpha type for the value of the TechDocsReaderPageContext
*/
export type TechDocsReaderPageValue = {
metadata: AsyncState<TechDocsMetadata>;
entityName: CompoundEntityRef;
entityMetadata: AsyncState<TechDocsEntityMetadata>;
shadowRoot?: ShadowRoot;
setShadowRoot: Dispatch<SetStateAction<ShadowRoot | undefined>>;
title: string;
setTitle: Dispatch<SetStateAction<string>>;
subtitle: string;
setSubtitle: Dispatch<SetStateAction<string>>;
/**
* @deprecated property can be passed down directly to the `TechDocsReaderPageContent` instead.
*/
onReady?: () => void;
};
/**
* @alpha
*/
export const defaultTechDocsReaderPageValue: TechDocsReaderPageValue = {
title: '',
subtitle: '',
setTitle: () => {},
setSubtitle: () => {},
setShadowRoot: () => {},
metadata: { loading: true },
entityMetadata: { loading: true },
entityName: { kind: '', name: '', namespace: '' },
};
/**
* @alpha
*/
export const TechDocsReaderPageContext = createVersionedContext<{
1: TechDocsReaderPageValue;
}>('techdocs-reader-page-context');
/**
* Hook used to get access to shared state between reader page components.
* @alpha
*/
export const useTechDocsReaderPage = () => {
const versionedContext = useContext(TechDocsReaderPageContext);
if (versionedContext === undefined) {
return defaultTechDocsReaderPageValue;
}
const context = versionedContext.atVersion(1);
if (context === undefined) {
throw new Error('No context found for version 1.');
}
return context;
};
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright 2022 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 {
useShadowRoot,
useShadowRootElements,
useShadowRootSelection,
} from './hooks';
import { renderHook } from '@testing-library/react-hooks';
import { fireEvent, waitFor } from '@testing-library/react';
const fireSelectionChangeEvent = (window: Window) => {
const selectionChangeEvent = window.document.createEvent('Event');
selectionChangeEvent.initEvent('selectionchange', true, true);
window.document.addEventListener('selectionchange', () => {}, false);
fireEvent(window.document, selectionChangeEvent);
};
const getSelection = jest.fn();
const mockShadowRoot = () => {
const div = document.createElement('div');
const shadowRoot = div.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '<h1>Shadow DOM Mock</h1>';
(shadowRoot as ShadowRoot & Pick<Document, 'getSelection'>).getSelection =
getSelection;
return shadowRoot;
};
const shadowRoot = mockShadowRoot();
jest.mock('./context', () => {
return {
useTechDocsReaderPage: () => ({ shadowRoot }),
};
});
const selection = {
type: 'Range',
rangeCount: 1,
isCollapsed: true,
getRangeAt: () => ({
startContainer: 'this is a sentence',
endContainer: 'this is a sentence',
startOffset: 1,
endOffset: 3,
getBoundingClientRect: () => ({
right: 100,
top: 100,
width: 100,
height: 100,
}),
}),
toString: () => 'his ',
containsNode: () => true,
} as unknown as Selection;
getSelection.mockReturnValue(selection);
describe('hooks', () => {
describe('useShadowRoot', () => {
it('should return shadow root', async () => {
const { result } = renderHook(() => useShadowRoot());
expect(result.current?.innerHTML).toBe(shadowRoot.innerHTML);
});
});
describe('useShadowRootElements', () => {
it('should return shadow root elements based on selector', () => {
const { result } = renderHook(() => useShadowRootElements(['h1']));
expect(result.current).toHaveLength(1);
});
});
describe('useShadowRootSelection', () => {
it('should return shadow root selection', async () => {
const { result } = renderHook(() => useShadowRootSelection(0));
expect(result.current).toBeNull();
fireSelectionChangeEvent(window);
await waitFor(() => {
expect(result.current?.toString()).toEqual('his ');
});
});
});
});
+95
View File
@@ -0,0 +1,95 @@
/*
* Copyright 2022 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 { useState, useEffect, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { useTechDocsReaderPage } from './context';
/**
* Hook for use within TechDocs addons that provides access to the underlying
* shadow root of the current page, allowing the DOM within to be mutated.
* @public
*/
export const useShadowRoot = () => {
const { shadowRoot } = useTechDocsReaderPage();
return shadowRoot;
};
/**
* Convenience hook for use within TechDocs addons that provides access to
* elements that match a given selector within the shadow root.
*
* @public
*/
export const useShadowRootElements = <
TReturnedElement extends HTMLElement = HTMLElement,
>(
selectors: string[],
): TReturnedElement[] => {
const shadowRoot = useShadowRoot();
if (!shadowRoot) return [];
return selectors
.map(selector => shadowRoot?.querySelectorAll<TReturnedElement>(selector))
.filter(nodeList => nodeList.length)
.map(nodeList => Array.from(nodeList))
.flat();
};
const isValidSelection = (newSelection: Selection) => {
// Safari sets the selection rect to top zero
return (
newSelection.toString() &&
newSelection.rangeCount &&
newSelection.getRangeAt(0).getBoundingClientRect().top
);
};
/**
* Hook for retreiving a selection within the ShadowRoot.
* @public
*/
export const useShadowRootSelection = (wait: number = 0) => {
const shadowRoot = useShadowRoot();
const [selection, setSelection] = useState<Selection | null>(null);
const handleSelectionChange = useMemo(
() =>
debounce(() => {
const shadowDocument = shadowRoot as ShadowRoot &
Pick<Document, 'getSelection'>;
// Firefox and Safari don't implement getSelection for Shadow DOM
const newSelection = shadowDocument.getSelection
? shadowDocument.getSelection()
: document.getSelection();
if (newSelection && isValidSelection(newSelection)) {
setSelection(newSelection);
} else {
setSelection(null);
}
}, wait),
[shadowRoot, setSelection, wait],
);
useEffect(() => {
window.document.addEventListener('selectionchange', handleSelectionChange);
return () =>
window.document.removeEventListener(
'selectionchange',
handleSelectionChange,
);
}, [handleSelectionChange]);
return selection;
};
+17 -2
View File
@@ -15,7 +15,7 @@
*/
/**
* Package encapsulating the TechDocs Addon framework.
* Package encapsulating utilities to be shared by frontend TechDocs plugins.
*
* @packageDocumentation
*/
@@ -26,5 +26,20 @@ export {
TechDocsAddons,
TECHDOCS_ADDONS_WRAPPER_KEY,
} from './addons';
export {
defaultTechDocsReaderPageValue,
TechDocsReaderPageContext,
useTechDocsReaderPage,
} from './context';
export type { TechDocsReaderPageValue } from './context';
export {
useShadowRoot,
useShadowRootElements,
useShadowRootSelection,
} from './hooks';
export { TechDocsAddonLocations } from './types';
export type { TechDocsAddonOptions } from './types';
export type {
TechDocsEntityMetadata,
TechDocsMetadata,
TechDocsAddonOptions,
} from './types';
+20
View File
@@ -15,6 +15,26 @@
*/
import { ComponentType } from 'react';
import { Entity } from '@backstage/catalog-model';
/**
* Metadata for TechDocs page
*
* @public
*/
export type TechDocsMetadata = {
site_name: string;
site_description: string;
};
/**
* Metadata for TechDocs Entity
*
* @public
*/
export type TechDocsEntityMetadata = Entity & {
locationMetadata?: { type: string; target: string };
};
/**
* Locations for which TechDocs addons may be declared and rendered.