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
+71
View File
@@ -1,5 +1,76 @@
# @backstage/plugin-techdocs
## 1.8.0
### Minor Changes
- 27740caa2d: Added experimental support for declarative integration via the `/alpha` subpath.
### Patch Changes
- 4918f65ab2: Create an experimental `TechDocsSearchResultItemExtension` for declarative integration with Backstage; it can be accessed via the `/alpha` import.
- 3605370af6: Improved `DocsTable` to display pagination controls dynamically, appearing only when needed.
- 0296f272b4: The `spec.lifecycle' field in entities will now always be rendered as a string.
- 9a1fce352e: Updated dependency `@testing-library/jest-dom` to `^6.0.0`.
- f95af4e540: Updated dependency `@testing-library/dom` to `^9.0.0`.
- 9468a67b92: Added support for React 18. The new `createRoot` API from `react-dom/client` will now be used if present.
- df449a7a31: Add kind column by default to TechDocsTable
- Updated dependencies
- @backstage/integration@1.7.1
- @backstage/plugin-catalog-react@1.8.5
- @backstage/frontend-plugin-api@0.2.0
- @backstage/core-plugin-api@1.7.0
- @backstage/core-components@0.13.6
- @backstage/integration-react@1.1.20
- @backstage/catalog-model@1.4.3
- @backstage/errors@1.2.3
- @backstage/plugin-search-react@1.7.1
- @backstage/plugin-techdocs-react@1.1.12
- @backstage/theme@0.4.3
- @backstage/config@1.1.1
- @backstage/plugin-search-common@1.2.7
## 1.7.1-next.2
### Patch Changes
- 3605370af6: Improved `DocsTable` to display pagination controls dynamically, appearing only when needed.
- Updated dependencies
- @backstage/frontend-plugin-api@0.2.0-next.2
- @backstage/integration-react@1.1.20-next.2
- @backstage/core-components@0.13.6-next.2
- @backstage/core-plugin-api@1.7.0-next.1
- @backstage/catalog-model@1.4.3-next.0
- @backstage/plugin-catalog-react@1.8.5-next.2
- @backstage/integration@1.7.1-next.1
- @backstage/errors@1.2.3-next.0
- @backstage/plugin-search-react@1.7.1-next.2
- @backstage/theme@0.4.3-next.0
- @backstage/config@1.1.1-next.0
- @backstage/plugin-search-common@1.2.7-next.0
- @backstage/plugin-techdocs-react@1.1.12-next.2
## 1.7.1-next.1
### Patch Changes
- 4918f65ab2: Create an experimental `TechDocsSearchResultItemExtension` for declarative integration with Backstage; it can be accessed via the `/alpha` import.
- df449a7a31: Add kind column by default to TechDocsTable
- Updated dependencies
- @backstage/frontend-plugin-api@0.1.1-next.1
- @backstage/core-components@0.13.6-next.1
- @backstage/plugin-search-react@1.7.1-next.1
- @backstage/integration-react@1.1.20-next.1
- @backstage/plugin-catalog-react@1.8.5-next.1
- @backstage/plugin-techdocs-react@1.1.12-next.1
- @backstage/core-plugin-api@1.7.0-next.0
- @backstage/config@1.1.0
- @backstage/catalog-model@1.4.2
- @backstage/errors@1.2.2
- @backstage/integration@1.7.1-next.0
- @backstage/theme@0.4.2
- @backstage/plugin-search-common@1.2.6
## 1.7.1-next.0
### Patch Changes
+35
View File
@@ -0,0 +1,35 @@
## API Report File for "@backstage/plugin-techdocs"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin<
{
root: RouteRef<undefined>;
docRoot: RouteRef<{
name: string;
kind: string;
namespace: string;
}>;
entityContent: RouteRef<undefined>;
},
{}
>;
export default _default;
// @alpha (undocumented)
export const TechDocsSearchResultListItemExtension: Extension<{
lineClamp: number;
noTrack: boolean;
asListItem: boolean;
asLink: boolean;
title?: string | undefined;
}>;
// (No @packageDocumentation comment for this package)
```
+23 -10
View File
@@ -1,14 +1,27 @@
{
"name": "@backstage/plugin-techdocs",
"description": "The Backstage plugin that renders technical documentation for your components",
"version": "1.7.1-next.0",
"version": "1.8.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "frontend-plugin"
@@ -39,6 +52,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
@@ -60,8 +74,8 @@
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
@@ -70,10 +84,9 @@
"@backstage/dev-utils": "workspace:^",
"@backstage/plugin-techdocs-module-addons-contrib": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/dom": "^8.0.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/dom": "^9.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/dompurify": "^2.2.2",
"@types/event-source-polyfill": "^1.0.0",
+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', () => {