Merge branch 'master' of https://github.com/backstage/backstage into techdocs/navigation-bugfix

This commit is contained in:
Sydney Achinger
2023-10-20 09:47:09 -04:00
1512 changed files with 53133 additions and 14131 deletions
+182
View File
@@ -0,0 +1,182 @@
/*
* Copyright 2023 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 LibraryBooks from '@material-ui/icons/LibraryBooks';
import {
createPlugin,
createSchemaFromZod,
createApiExtension,
createPageExtension,
createNavItemExtension,
} from '@backstage/frontend-plugin-api';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
import {
configApiRef,
createApiFactory,
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import {
techdocsApiRef,
techdocsStorageApiRef,
} from '@backstage/plugin-techdocs-react';
import { TechDocsClient, TechDocsStorageClient } from './client';
import {
rootCatalogDocsRouteRef,
rootDocsRouteRef,
rootRouteRef,
} from './routes';
import { createEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
/** @alpha */
const techDocsStorage = createApiExtension({
api: techdocsStorageApiRef,
factory() {
return createApiFactory({
api: techdocsStorageApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi, fetchApi }) =>
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
}),
});
},
});
/** @alpha */
const techDocsClient = createApiExtension({
api: techdocsApiRef,
factory() {
return createApiFactory({
api: techdocsApiRef,
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, fetchApi }) =>
new TechDocsClient({
configApi,
discoveryApi,
fetchApi,
}),
});
},
});
/** @alpha */
export const TechDocsSearchResultListItemExtension =
createSearchResultListItemExtension({
id: 'techdocs',
configSchema: createSchemaFromZod(z =>
z.object({
// TODO: Define how the icon can be configurable
title: z.string().optional(),
lineClamp: z.number().default(5),
asLink: z.boolean().default(true),
asListItem: z.boolean().default(true),
noTrack: z.boolean().default(false),
}),
),
predicate: result => result.type === 'techdocs',
component: async ({ config }) => {
const { TechDocsSearchResultListItem } = await import(
'./search/components/TechDocsSearchResultListItem'
);
return props => <TechDocsSearchResultListItem {...props} {...config} />;
},
});
/**
* Responsible for rendering the provided router element
*
* @alpha
*/
const TechDocsIndexPage = createPageExtension({
id: 'plugin.techdocs.indexPage',
defaultPath: '/docs',
routeRef: convertLegacyRouteRef(rootRouteRef),
loader: () =>
import('./home/components/TechDocsIndexPage').then(m => (
<m.TechDocsIndexPage />
)),
});
/**
* Component responsible for composing a TechDocs reader page experience
*
* @alpha
*/
const TechDocsReaderPage = createPageExtension({
id: 'plugin.techdocs.readerPage',
defaultPath: '/docs/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(rootDocsRouteRef),
loader: () =>
import('./reader/components/TechDocsReaderPage').then(m => (
<m.TechDocsReaderPage />
)),
});
/**
* Component responsible for rendering techdocs on entity pages
*
* @alpha
*/
const TechDocsEntityContent = createEntityContentExtension({
id: 'techdocs',
defaultPath: 'docs',
defaultTitle: 'TechDocs',
loader: () => import('./Router').then(m => <m.EmbeddedDocsRouter />),
});
/** @alpha */
const TechDocsNavItem = createNavItemExtension({
id: 'plugin.techdocs.nav.index',
icon: LibraryBooks,
title: 'Docs',
routeRef: convertLegacyRouteRef(rootRouteRef),
});
/** @alpha */
export default createPlugin({
id: 'techdocs',
extensions: [
techDocsClient,
techDocsStorage,
TechDocsNavItem,
TechDocsIndexPage,
TechDocsReaderPage,
TechDocsEntityContent,
TechDocsSearchResultListItemExtension,
],
routes: {
root: convertLegacyRouteRef(rootRouteRef),
docRoot: convertLegacyRouteRef(rootDocsRouteRef),
entityContent: convertLegacyRouteRef(rootCatalogDocsRouteRef),
},
});
@@ -95,14 +95,17 @@ export const DocsTable = (props: DocsTableProps) => {
actionFactories.createCopyDocsUrlAction(copyToClipboard),
];
const pageSize = 20;
const paging = documents && documents.length > pageSize;
return (
<>
{loading || (documents && documents.length > 0) ? (
<Table<DocsTableRow>
isLoading={loading}
options={{
paging: true,
pageSize: 20,
paging,
pageSize,
search: true,
actionsColumnIndex: -1,
...options,
@@ -20,6 +20,7 @@ import {
TechDocsBuildLogs,
TechDocsBuildLogsDrawerContent,
} from './TechDocsBuildLogs';
import { userEvent } from '@testing-library/user-event';
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
jest.mock('react-virtualized-auto-sizer', () => ({
@@ -38,7 +39,7 @@ describe('<TechDocsBuildLogs />', () => {
it('should open drawer', async () => {
const rendered = await renderInTestApp(<TechDocsBuildLogs buildLog={[]} />);
rendered.getByText(/Show Build Logs/i).click();
await userEvent.click(rendered.getByText(/Show Build Logs/i));
expect(rendered.getByText(/Build Details/i)).toBeInTheDocument();
});
});
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import React from 'react';
import { act } from '@testing-library/react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
@@ -150,31 +149,25 @@ describe('<TechDocsReaderPage />', () => {
});
it('should render a techdocs reader page with children', async () => {
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test',
}}
>
techdocs reader page
</TechDocsReaderPage>
</Wrapper>,
{
mountedRoutes,
},
);
expect(
rendered.container.querySelector('header'),
).not.toBeInTheDocument();
expect(
rendered.container.querySelector('article'),
).not.toBeInTheDocument();
expect(rendered.getByText('techdocs reader page')).toBeInTheDocument();
});
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test',
}}
>
techdocs reader page
</TechDocsReaderPage>
</Wrapper>,
{
mountedRoutes,
},
);
expect(rendered.container.querySelector('header')).not.toBeInTheDocument();
expect(rendered.container.querySelector('article')).not.toBeInTheDocument();
expect(rendered.getByText('techdocs reader page')).toBeInTheDocument();
});
it('should render techdocs reader page with addons', async () => {
@@ -183,56 +176,52 @@ describe('<TechDocsReaderPage />', () => {
const namespace = 'test-namespace';
const kind = 'test';
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<FlatRoutes>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
</FlatRoutes>
</Wrapper>,
{
mountedRoutes,
routeEntries: ['/docs/test-namespace/test/test-name'],
},
);
const rendered = await renderInTestApp(
<Wrapper>
<FlatRoutes>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
</FlatRoutes>
</Wrapper>,
{
mountedRoutes,
routeEntries: ['/docs/test-namespace/test/test-name'],
},
);
expect(
rendered.getByText(`PageMock: ${namespace}#${kind}#${name}`),
).toBeInTheDocument();
});
expect(
rendered.getByText(`PageMock: ${namespace}#${kind}#${name}`),
).toBeInTheDocument();
});
it('should render techdocs reader page with addons and page', async () => {
(Page as jest.Mock).mockImplementation(PageMock);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<FlatRoutes>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
<p>the page</p>
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
</FlatRoutes>
</Wrapper>,
{
mountedRoutes,
routeEntries: ['/docs/test-namespace/test/test-name'],
},
);
const rendered = await renderInTestApp(
<Wrapper>
<FlatRoutes>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
<p>the page</p>
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
</FlatRoutes>
</Wrapper>,
{
mountedRoutes,
routeEntries: ['/docs/test-namespace/test/test-name'],
},
);
expect(rendered.getByText('the page')).toBeInTheDocument();
});
expect(rendered.getByText('the page')).toBeInTheDocument();
});
});
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { renderHook, waitFor } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
@@ -95,16 +95,13 @@ describe('context', () => {
});
it('should return expected entity values', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useEntityMetadata(),
{ wrapper },
);
const { result } = renderHook(() => useEntityMetadata(), { wrapper });
await waitForNextUpdate();
expect(result.current.value).toBeDefined();
expect(result.current.error).toBeUndefined();
expect(result.current.value).toMatchObject(mockEntityMetadata);
await waitFor(() => {
expect(result.current.value).toBeDefined();
expect(result.current.error).toBeUndefined();
expect(result.current.value).toMatchObject(mockEntityMetadata);
});
});
});
@@ -116,16 +113,13 @@ describe('context', () => {
});
it('should return expected techdocs metadata values', async () => {
const { result, waitForNextUpdate } = renderHook(
() => useTechDocsMetadata(),
{ wrapper },
);
const { result } = renderHook(() => useTechDocsMetadata(), { wrapper });
await waitForNextUpdate();
expect(result.current.value).toBeDefined();
expect(result.current.error).toBeUndefined();
expect(result.current.value).toMatchObject(mockTechDocsMetadata);
await waitFor(() => {
expect(result.current.value).toBeDefined();
expect(result.current.error).toBeUndefined();
expect(result.current.value).toMatchObject(mockTechDocsMetadata);
});
});
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { act, waitFor } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
@@ -98,18 +98,16 @@ describe('<TechDocsReaderPageContent />', () => {
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.getByTestId('techdocs-native-shadowroot'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
rendered.getByTestId('techdocs-native-shadowroot'),
).toBeInTheDocument();
});
});
@@ -118,21 +116,19 @@ describe('<TechDocsReaderPageContent />', () => {
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.queryByTestId('techdocs-native-shadowroot'),
).not.toBeInTheDocument();
expect(
rendered.getByText('ERROR 404: PAGE NOT FOUND'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
rendered.queryByTestId('techdocs-native-shadowroot'),
).not.toBeInTheDocument();
expect(
rendered.getByText('ERROR 404: PAGE NOT FOUND'),
).toBeInTheDocument();
});
});
@@ -142,21 +138,19 @@ describe('<TechDocsReaderPageContent />', () => {
useTechDocsReaderDom.mockReturnValue(undefined);
useReaderState.mockReturnValue({ state: 'CONTENT_NOT_FOUND' });
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.queryByTestId('techdocs-native-shadowroot'),
).not.toBeInTheDocument();
expect(
rendered.getByText('ERROR 404: Documentation not found'),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
rendered.queryByTestId('techdocs-native-shadowroot'),
).not.toBeInTheDocument();
expect(
rendered.getByText('ERROR 404: Documentation not found'),
).toBeInTheDocument();
});
});
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import React from 'react';
import { act, waitFor } from '@testing-library/react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
@@ -80,112 +79,77 @@ describe('<TechDocsReaderPageHeader />', () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
);
},
);
expect(rendered.container.innerHTML).toContain('header');
expect(rendered.container.innerHTML).toContain('header');
await waitFor(() => {
expect(rendered.getAllByText('test-site-name')).toHaveLength(2);
});
expect(rendered.getAllByText('test-site-name')).toHaveLength(2);
expect(rendered.getByText('test-site-desc')).toBeDefined();
expect(rendered.getByText('test-site-desc')).toBeDefined();
});
expect(
rendered.getByRole('link', { name: 'test:test-namespace/test-name' }),
).toHaveAttribute('href', '/catalog/test-namespace/test/test-name');
});
it('should render a techdocs page header even if metadata is not loaded', async () => {
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
);
},
);
expect(rendered.container.innerHTML).toContain('header');
});
expect(rendered.container.innerHTML).toContain('header');
});
it('should not render a techdocs page header if entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
);
},
);
await waitFor(() => {
expect(rendered.container.innerHTML).not.toContain('header');
});
});
expect(rendered.container.innerHTML).not.toContain('header');
});
it('should not render a techdocs page header if techdocs metadata is missing', async () => {
getTechDocsMetadata.mockResolvedValue(undefined);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
);
},
);
await waitFor(() => {
expect(rendered.container.innerHTML).not.toContain('header');
});
});
});
it('should render a link back to the component page', async () => {
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
await act(async () => {
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageHeader />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
},
},
);
await waitFor(() => {
expect(
rendered.getByRole('link', { name: 'test:test-namespace/test-name' }),
).toHaveAttribute('href', '/catalog/test-namespace/test/test-name');
});
});
expect(rendered.container.innerHTML).not.toContain('header');
});
});
@@ -133,7 +133,9 @@ export const TechDocsReaderPageHeader = (
}
/>
)}
{lifecycle ? <HeaderLabel label="Lifecycle" value={lifecycle} /> : null}
{lifecycle ? (
<HeaderLabel label="Lifecycle" value={String(lifecycle)} />
) : null}
{locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file' ? (
@@ -16,7 +16,7 @@
import { NotFoundError } from '@backstage/errors';
import { TestApiProvider } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import { act, renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { techdocsStorageApiRef } from '../../api';
import {
@@ -284,24 +284,22 @@ describe('useReaderState', () => {
return 'cached';
});
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
const { result } = renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitForValueToChange(() => result.current.state);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
@@ -311,20 +309,20 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
it('should reload initially missing content', async () => {
@@ -336,6 +334,7 @@ describe('useReaderState', () => {
});
techdocsStorageApi.syncEntityDocs.mockImplementation(
async (_, logHandler) => {
await 'a tick';
logHandler?.call(this, 'Line 1');
logHandler?.call(this, 'Line 2');
await new Promise(resolve => setTimeout(resolve, 1100));
@@ -343,38 +342,39 @@ describe('useReaderState', () => {
},
);
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
const { result } = renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
await waitForValueToChange(() => result.current.state, {
await waitFor(
() => {
expect(result.current).toEqual({
state: 'INITIAL_BUILD',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Page Not Found',
syncErrorMessage: undefined,
buildLog: ['Line 1', 'Line 2'],
contentReload: expect.any(Function),
});
},
{
timeout: 2000,
});
expect(result.current).toEqual({
state: 'INITIAL_BUILD',
path: '/example',
content: undefined,
contentErrorMessage: 'NotFoundError: Page Not Found',
syncErrorMessage: undefined,
buildLog: ['Line 1', 'Line 2'],
contentReload: expect.any(Function),
});
await waitForValueToChange(() => result.current.state);
},
);
await waitFor(() => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
@@ -384,9 +384,9 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
});
await waitForValueToChange(() => result.current.state);
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
@@ -396,22 +396,22 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledTimes(2);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledTimes(1);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledTimes(2);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledTimes(1);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
it('should handle stale content', async () => {
@@ -423,6 +423,7 @@ describe('useReaderState', () => {
});
techdocsStorageApi.syncEntityDocs.mockImplementation(
async (_, logHandler) => {
await 'a tick';
logHandler?.call(this, 'Line 1');
logHandler?.call(this, 'Line 2');
await new Promise(resolve => setTimeout(resolve, 1100));
@@ -430,24 +431,23 @@ describe('useReaderState', () => {
},
);
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
const { result } = renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// the content is returned but the sync is in progress
await waitForValueToChange(() => result.current.state);
// the content is returned but the sync is in progress
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
@@ -457,9 +457,10 @@ describe('useReaderState', () => {
buildLog: ['Line 1', 'Line 2'],
contentReload: expect.any(Function),
});
});
// the sync takes longer than 1 seconds so the refreshing state starts
await waitForValueToChange(() => result.current.state);
// the sync takes longer than 1 seconds so the refreshing state starts
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_STALE_REFRESHING',
path: '/example',
@@ -469,9 +470,10 @@ describe('useReaderState', () => {
buildLog: ['Line 1', 'Line 2'],
contentReload: expect.any(Function),
});
});
// the content is updated but not yet displayed
await waitForValueToChange(() => result.current.state);
// the content is updated but not yet displayed
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_STALE_READY',
path: '/example',
@@ -481,12 +483,15 @@ describe('useReaderState', () => {
buildLog: ['Line 1', 'Line 2'],
contentReload: expect.any(Function),
});
});
// reload the content
// reload the content
await act(async () => {
result.current.contentReload();
});
// the new content refresh is triggered
await waitForValueToChange(() => result.current.state);
// the new content refresh is triggered
await waitFor(() => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
@@ -496,35 +501,39 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
// the new content is loaded
await waitForValueToChange(() => result.current.state, {
timeout: 2000,
});
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledTimes(2);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
// the new content is loaded
await waitFor(
() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
},
{
timeout: 2000,
},
);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledTimes(2);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
it('should handle navigation', async () => {
@@ -537,25 +546,24 @@ describe('useReaderState', () => {
.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 },
);
const { result, rerender } = 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),
});
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);
// show the content
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/example',
@@ -565,11 +573,12 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
});
// navigate
rerender({ path: '/new' });
// navigate
rerender({ path: '/new' });
await waitForValueToChange(() => result.current.state);
await waitFor(() => {
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
@@ -579,24 +588,29 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
});
await waitForValueToChange(() => result.current.state, {
await waitFor(
() => {
expect(result.current).toEqual({
state: 'CONTENT_FRESH',
path: '/new',
content: 'my new content',
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
},
{
timeout: 2000,
});
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' });
// navigate
rerender({ path: '/missing' });
await waitForValueToChange(() => result.current.state);
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/missing',
@@ -606,24 +620,24 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/new',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/new',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
it('should handle content error', async () => {
@@ -632,24 +646,23 @@ describe('useReaderState', () => {
);
techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached');
await act(async () => {
const { result, waitForValueToChange } = await renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
const { result } = renderHook(
() => useReaderState('Component', 'default', 'backstage', '/example'),
{ wrapper: Wrapper },
);
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
expect(result.current).toEqual({
state: 'CHECKING',
path: '/example',
content: undefined,
contentErrorMessage: undefined,
syncErrorMessage: undefined,
buildLog: [],
contentReload: expect.any(Function),
});
// the content loading threw an error
await waitForValueToChange(() => result.current.state);
// the content loading threw an error
await waitFor(() => {
expect(result.current).toEqual({
state: 'CONTENT_NOT_FOUND',
path: '/example',
@@ -659,20 +672,20 @@ describe('useReaderState', () => {
buildLog: [],
contentReload: expect.any(Function),
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
expect(techdocsStorageApi.getEntityDocs).toHaveBeenCalledWith(
{ kind: 'Component', namespace: 'default', name: 'backstage' },
'/example',
);
expect(techdocsStorageApi.syncEntityDocs).toHaveBeenCalledWith(
{
kind: 'Component',
namespace: 'default',
name: 'backstage',
},
expect.any(Function),
);
});
});
});
@@ -21,8 +21,8 @@ import {
} 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';
import { renderReactElement } from './renderReactElement';
// requires repo
export const addGitFeedbackLink = (
@@ -75,7 +75,7 @@ export const addGitFeedbackLink = (
default:
return dom;
}
ReactDOM.render(React.createElement(FeedbackOutlinedIcon), feedbackLink);
renderReactElement(React.createElement(FeedbackOutlinedIcon), feedbackLink);
feedbackLink.style.paddingLeft = '5px';
feedbackLink.title = 'Leave feedback for this page';
feedbackLink.id = 'git-feedback-link';
@@ -17,7 +17,7 @@
import type { Transformer } from './transformer';
import MenuIcon from '@material-ui/icons/Menu';
import React from 'react';
import ReactDOM from 'react-dom';
import { renderReactElement } from './renderReactElement';
export const addSidebarToggle = (): Transformer => {
return dom => {
@@ -33,7 +33,7 @@ export const addSidebarToggle = (): Transformer => {
}
const toggleSidebar = mkdocsToggleSidebar.cloneNode() as HTMLLabelElement;
ReactDOM.render(React.createElement(MenuIcon), toggleSidebar);
renderReactElement(React.createElement(MenuIcon), toggleSidebar);
toggleSidebar.id = 'toggle-sidebar';
toggleSidebar.title = 'Toggle Sidebar';
toggleSidebar.classList.add('md-content__button');
@@ -17,7 +17,7 @@
import { createTestShadowDom } from '../../test-utils';
import { copyToClipboard } from './copyToClipboard';
import { lightTheme } from '@backstage/theme';
import { waitFor } from '@testing-library/react';
import { act, waitFor } from '@testing-library/react';
import useCopyToClipboard from 'react-use/lib/useCopyToClipboard';
const clipboardSpy = jest.fn();
@@ -43,8 +43,11 @@ describe('copyToClipboard', () => {
spy.mockReturnValue([{}, copy]);
const expectedClipboard = 'function foo() {return "bar";}';
const shadowDom = await createTestShadowDom(
`
let shadowDom: ShadowRoot;
await act(async () => {
shadowDom = await createTestShadowDom(
`
<!DOCTYPE html>
<html>
<body>
@@ -52,13 +55,20 @@ describe('copyToClipboard', () => {
</body>
</html>
`,
{
preTransformers: [],
postTransformers: [copyToClipboard(lightTheme)],
},
);
{
preTransformers: [],
postTransformers: [copyToClipboard(lightTheme)],
},
);
});
shadowDom.querySelector('button')?.click();
await waitFor(() => {
expect(shadowDom.querySelector('button')).not.toBe(null);
});
await act(async () => {
shadowDom.querySelector('button')!.click();
});
await waitFor(() => {
const tooltip = document.querySelector('[role="tooltip"]');
@@ -15,7 +15,7 @@
*/
import React, { useState, useCallback } from 'react';
import ReactDom from 'react-dom';
import { renderReactElement } from './renderReactElement';
import {
withStyles,
Theme,
@@ -91,7 +91,7 @@ export const copyToClipboard = (theme: Theme): Transformer => {
const text = code.textContent || '';
const container = document.createElement('div');
code?.parentElement?.prepend(container);
ReactDom.render(
renderReactElement(
<ThemeProvider theme={theme}>
<CopyToClipboardButton text={text} />
</ThemeProvider>,
@@ -15,7 +15,7 @@
*/
import React, { FC, PropsWithChildren } from 'react';
import { renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react';
import { ConfigReader } from '@backstage/core-app-api';
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
@@ -0,0 +1,35 @@
/*
* Copyright 2023 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.
*/
let ReactDOMPromise: Promise<
typeof import('react-dom') | typeof import('react-dom/client')
>;
if (process.env.HAS_REACT_DOM_CLIENT) {
ReactDOMPromise = import('react-dom/client');
} else {
ReactDOMPromise = import('react-dom');
}
/** @internal */
export function renderReactElement(element: JSX.Element, root: HTMLElement) {
ReactDOMPromise.then(ReactDOM => {
if ('createRoot' in ReactDOM) {
ReactDOM.createRoot(root).render(element);
} else {
ReactDOM.render(element, root);
}
});
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react';
import { useStylesTransformer } from './transformer';
describe('Transformers > Styles', () => {