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:
@@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user