Merge remote-tracking branch 'backstage/master' into fix/14026

This commit is contained in:
Matteo Pietro Dazzi
2022-10-26 22:12:33 +02:00
1209 changed files with 31855 additions and 7306 deletions
+82
View File
@@ -1,5 +1,87 @@
# @backstage/plugin-techdocs
## 1.4.0-next.0
### Minor Changes
- 5691baea69: Add ability to configure filters when using EntityListDocsGrid
The following example will render two sections of cards grid:
- One section for documentations tagged as `recommended`
- One section for documentations tagged as `runbook`
```js
<EntityListDocsGrid groups={{[
{
title: "Recommended Documentation",
filterPredicate: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
{
title: "RunBooks Documentation",
filterPredicate: entity =>
entity?.metadata?.tags?.includes('runbook') ?? false,
}
]}} />
```
### Patch Changes
- cbe11d1e23: Tweak README
- 7573b65232: Internal refactor of imports to avoid circular dependencies
- c1784a4980: Replaces in-code uses of `GitHub` with `Github` and deprecates old versions.
- 3a1a999b7b: Include query parameters when navigating to relative links in documents
- Updated dependencies
- @backstage/plugin-catalog-react@1.2.1-next.0
- @backstage/core-components@0.12.0-next.0
- @backstage/core-plugin-api@1.1.0-next.0
- @backstage/integration@1.4.0-next.0
- @backstage/catalog-model@1.1.3-next.0
- @backstage/integration-react@1.1.6-next.0
- @backstage/plugin-search-react@1.2.1-next.0
- @backstage/plugin-techdocs-react@1.0.6-next.0
- @backstage/config@1.0.4-next.0
- @backstage/errors@1.1.3-next.0
- @backstage/theme@0.2.16
- @backstage/plugin-search-common@1.1.1-next.0
## 1.3.3
### Patch Changes
- Updated dependencies
- @backstage/catalog-model@1.1.2
- @backstage/plugin-catalog-react@1.2.0
- @backstage/core-components@0.11.2
- @backstage/plugin-search-react@1.2.0
- @backstage/plugin-search-common@1.1.0
- @backstage/plugin-techdocs-react@1.0.5
- @backstage/integration-react@1.1.5
- @backstage/core-plugin-api@1.0.7
- @backstage/config@1.0.3
- @backstage/errors@1.1.2
- @backstage/integration@1.3.2
- @backstage/theme@0.2.16
## 1.3.3-next.2
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@1.2.0-next.2
- @backstage/plugin-search-common@1.1.0-next.2
- @backstage/catalog-model@1.1.2-next.2
- @backstage/config@1.0.3-next.2
- @backstage/core-components@0.11.2-next.2
- @backstage/core-plugin-api@1.0.7-next.2
- @backstage/errors@1.1.2-next.2
- @backstage/integration@1.3.2-next.2
- @backstage/integration-react@1.1.5-next.2
- @backstage/theme@0.2.16
- @backstage/plugin-search-react@1.2.0-next.2
- @backstage/plugin-techdocs-react@1.0.5-next.2
## 1.3.3-next.1
### Patch Changes
+14 -1
View File
@@ -59,6 +59,12 @@ export type DocsCardGridProps = {
entities: Entity[] | undefined;
};
// @public
export type DocsGroupConfig = {
title: React_2.ReactNode;
filterPredicate: ((entity: Entity) => boolean) | string;
};
// @public
export const DocsTable: {
(props: DocsTableProps): JSX.Element | null;
@@ -112,7 +118,14 @@ export const EmbeddedDocsRouter: (
) => JSX.Element | null;
// @public
export const EntityListDocsGrid: () => JSX.Element;
export const EntityListDocsGrid: ({
groups,
}: EntityListDocsGridPageProps) => JSX.Element;
// @public
export type EntityListDocsGridPageProps = {
groups?: DocsGroupConfig[];
};
// @public
export const EntityListDocsTable: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-techdocs",
"description": "The Backstage plugin that renders technical documentation for your components",
"version": "1.3.3-next.1",
"version": "1.4.0-next.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -0,0 +1,203 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
import {
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import {
CatalogApi,
catalogApiRef,
starredEntitiesApiRef,
MockEntityListContextProvider,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
renderInTestApp,
TestApiRegistry,
} from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { rootDocsRouteRef } from '../../../routes';
import { EntityListDocsGrid } from './EntityListDocsGrid';
const entities = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Documentation #1',
namespace: 'default',
},
spec: {
type: 'documentation',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Documentation #2',
namespace: 'default',
},
spec: {
type: 'documentation',
},
},
];
const mockCatalogApi = {
getEntityByRef: () => Promise.resolve(),
getEntities: async () => ({
items: entities,
}),
} as Partial<CatalogApi>;
describe('Entity List Docs Grid', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
},
});
const storageApi = MockStorageApi.create();
const apiRegistry = TestApiRegistry.from(
[catalogApiRef, mockCatalogApi],
[configApiRef, configApi],
[storageApiRef, storageApi],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
);
it('should render all entitites without filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(await screen.queryByText('All Documentation')).toBeInTheDocument();
expect(await screen.queryByText('Documentation #1')).toBeInTheDocument();
expect(await screen.queryByText('Documentation #2')).toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render only filtered entities with filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid
groups={[
{
title: 'Curated Documentation',
filterPredicate: entity =>
entity.metadata.name === 'Documentation #1',
},
]}
/>
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('Curated Documentation'),
).toBeInTheDocument();
expect(await screen.queryByText('Documentation #1')).toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render nothing with filtering yielding no result', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: entities }}>
<EntityListDocsGrid
groups={[
{
title: 'Curated Documentation',
filterPredicate: entity =>
entity.metadata.name === 'Documentation #3',
},
]}
/>
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('Curated Documentation'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #1'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).not.toBeInTheDocument();
});
it('should render an error without any documentation and without filtering', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<MockEntityListContextProvider value={{ entities: [] }}>
<EntityListDocsGrid />
</MockEntityListContextProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
expect(
await screen.queryByText('All Documentation'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #1'),
).not.toBeInTheDocument();
expect(
await screen.queryByText('Documentation #2'),
).not.toBeInTheDocument();
expect(await screen.queryByTestId('doc-not-found')).toBeInTheDocument();
});
});
@@ -15,20 +15,95 @@
*/
import { DocsCardGrid } from './DocsCardGrid';
import { Entity } from '@backstage/catalog-model';
import {
CodeSnippet,
Content,
ContentHeader,
Link,
Progress,
WarningPanel,
} from '@backstage/core-components';
import { useEntityList } from '@backstage/plugin-catalog-react';
import {
useEntityList,
useEntityOwnership,
} from '@backstage/plugin-catalog-react';
import { Typography } from '@material-ui/core';
import React from 'react';
/**
* Props for {@link EntityListDocsGrid}
*
* @public
*/
export type DocsGroupConfig = {
title: React.ReactNode;
filterPredicate: ((entity: Entity) => boolean) | string;
};
/**
* Props for {@link EntityListDocsGrid}
*
* @public
*/
export type EntityListDocsGridPageProps = {
groups?: DocsGroupConfig[];
};
const allEntitiesGroup: DocsGroupConfig = {
title: 'All Documentation',
filterPredicate: () => true,
};
const EntityListDocsGridGroup = ({
entities,
group,
}: {
group: DocsGroupConfig;
entities: Entity[];
}) => {
const { loading: loadingOwnership, isOwnedEntity } = useEntityOwnership();
const shownEntities = entities.filter(entity => {
if (group.filterPredicate === 'ownedByUser') {
if (loadingOwnership) {
return false;
}
return isOwnedEntity(entity);
}
return (
typeof group.filterPredicate === 'function' &&
group.filterPredicate(entity)
);
});
const titleComponent: React.ReactNode = (() => {
return typeof group.title === 'string' ? (
<ContentHeader title={group.title} />
) : (
group.title
);
})();
if (shownEntities.length === 0) {
return null;
}
return (
<Content>
{titleComponent}
<DocsCardGrid entities={shownEntities} />
</Content>
);
};
/**
* Component responsible to get entities from entity list context and pass down to DocsCardGrid
*
* @public
*/
export const EntityListDocsGrid = () => {
export const EntityListDocsGrid = ({ groups }: EntityListDocsGridPageProps) => {
const { loading, error, entities } = useEntityList();
if (error) {
@@ -42,15 +117,39 @@ export const EntityListDocsGrid = () => {
);
}
if (loading || !entities) {
if (loading) {
return <Progress />;
}
if (entities.length === 0) {
return (
<div data-testid="doc-not-found">
<Typography variant="body2">
No documentation found that match your filter. Learn more about{' '}
<Link to="https://backstage.io/docs/features/techdocs/creating-and-publishing">
publishing documentation
</Link>
.
</Typography>
</div>
);
}
entities.sort((a, b) =>
(a.metadata.title ?? a.metadata.name).localeCompare(
b.metadata.title ?? b.metadata.name,
),
);
return <DocsCardGrid entities={entities} />;
return (
<Content>
{(groups || [allEntitiesGroup]).map((group, index: number) => (
<EntityListDocsGridGroup
entities={entities}
group={group}
key={`${group.title}-${index}`}
/>
))}
</Content>
);
};
+1 -1
View File
@@ -17,4 +17,4 @@ export const updateH1Text = (): Transformer => {
};
```
The transformers are then registered in the Reader.tsx file. They are registered in two places, one place that runs before it's attached to the actual browser DOM (preTransformers) and once after (postTransfomers). Doing modifications is faster before it's attached, but doesn't allow us to do some things, such as attaching event listeners.
The transformers are then registered in the Reader.tsx file. They are registered in two places, one place that runs before it's attached to the actual browser DOM (`preTransformer`s) and once after (`postTransformer`s). Doing modifications is faster before it's attached, but doesn't allow us to do some things, such as attaching event listeners.
@@ -35,6 +35,82 @@ import {
useRouteRefParams,
} from '@backstage/core-plugin-api';
/* An explanation for the multiple ways of customizing the TechDocs reader page
Please refer to this page on the microsite for the latest recommended approach:
https://backstage.io/docs/features/techdocs/how-to-guides#how-to-customize-the-techdocs-reader-page
The <TechDocsReaderPage> component is responsible for rendering the <TechDocsReaderPageProvider> and
its contained version of a <Page>, which in turn renders the <TechDocsReaderPageContent>.
Historically, there have been different approaches on how this <Page> can be customized, and how the
<TechDocsReaderPageContent> inside could be exchanged for a custom implementation (which was not
possible before). Also, the current implementation supports every scenario to avoid breaking default
configurations of TechDocs.
In particular, there are 4 different TechDocs page configurations:
CONFIGURATION 1: <TechDocsReaderPage> only, no children
<Route path="/docs/:namespace/:kind/:name/*" element={<TechDocsReaderPage />} >
This is the simplest way to use TechDocs. Only a full page is passed, assuming that it comes with
its content inside. Since we allowed customizing it, we started providing <TechDocsReaderLayout> as
a default implementation (which contains <TechDocsReaderPageContent>).
CONFIGURATION 2 (not advised): <TechDocsReaderPage> with element children
<Route
path="/docs/:namespace/:kind/:name/*"
element={
<TechDocsReaderPage>
{techdocsPage}
</TechDocsReaderPage>
}
/>
Previously, there were two ways of passing children to <TechDocsReaderPage>: either as elements (as
shown above), or as a render function (described below in CONFIGURATION 3). The "techdocsPage" is
located in packages/app/src/components/techdocs and is the default implementation of the content
inside.
CONFIGURATION 3 (not advised): <TechDocsReaderPage> with render function as child
<Route
path="/docs/:namespace/:kind/:name/*"
element={
<TechDocsReaderPage>
{({ metadata, entityMetadata, onReady }) => (
techdocsPage
)}
</TechDocsReaderPage>
}
/>
Similar to CONFIGURATION 2, the direct children will be passed to the <TechDocsReaderPage> but in
this case interpreted as render prop.
CONFIGURATION 4: <TechDocsReaderPage> and provided content in <Route>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
>
{techDocsPage}
<TechDocsAddons>
<ExpandableNavigation />
<ReportIssue />
<TextSize />
</TechDocsAddons>
</Route>
This is the current state in packages/app/src/App.tsx and moved the location of children from inside
the element prop in the <Route> to the children of the <Route>. Then, in <TechDocsReaderPage> they
are retrieved using the useOutlet hook from React Router.
NOTE: Render functions are no longer supported in this approach.
*/
/**
* Props for {@link TechDocsReaderLayout}
* @public
@@ -77,6 +153,7 @@ export type TechDocsReaderPageProps = {
/**
* An addon-aware implementation of the TechDocsReaderPage.
*
* @public
*/
export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
@@ -88,21 +165,14 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
if (!children) {
const childrenList = outlet ? Children.toArray(outlet.props.children) : [];
const page = childrenList.find(child => {
if (getComponentData(child, TECHDOCS_ADDONS_WRAPPER_KEY)) {
return false;
}
// react-router 6 stable wraps children in a routing context provider, so check one level deeper
const nestedChildren = (child as ReactElement)?.props?.children;
if (nestedChildren) {
return !Children.toArray(nestedChildren).some(nested =>
getComponentData(nested, TECHDOCS_ADDONS_WRAPPER_KEY),
);
}
return true;
});
const grandChildren = childrenList.flatMap(
child => (child as ReactElement)?.props?.children ?? [],
);
const page: React.ReactNode = grandChildren.find(
grandChild => !getComponentData(grandChild, TECHDOCS_ADDONS_WRAPPER_KEY),
);
// As explained above, "page" is configuration 4 and <TechDocsReaderLayout> is 1
return (
<TechDocsReaderPageProvider entityRef={entityRef}>
{(page as JSX.Element) || <TechDocsReaderLayout />}
@@ -110,6 +180,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
);
}
// As explained above, a render function is configuration 3 and React element is 2
return (
<TechDocsReaderPageProvider entityRef={entityRef}>
{({ metadata, entityMetadata, onReady }) => (
@@ -175,13 +175,14 @@ export const useTechDocsReaderDom = (
// detect if CTRL or META keys are pressed so that links can be opened in a new tab with `window.open`
const modifierActive = event.ctrlKey || event.metaKey;
const parsedUrl = new URL(url);
const fullPath = `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
// hash exists when anchor is clicked on secondary sidebar
if (parsedUrl.hash) {
if (modifierActive) {
window.open(`${parsedUrl.pathname}${parsedUrl.hash}`, '_blank');
window.open(fullPath, '_blank');
} else {
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
navigate(fullPath);
// Scroll to hash if it's on the current page
transformedElement
?.querySelector(`[id="${parsedUrl.hash.slice(1)}"]`)
@@ -189,9 +190,9 @@ export const useTechDocsReaderDom = (
}
} else {
if (modifierActive) {
window.open(parsedUrl.pathname, '_blank');
window.open(fullPath, '_blank');
} else {
navigate(parsedUrl.pathname);
navigate(fullPath);
}
}
},
@@ -14,9 +14,9 @@
* limitations under the License.
*/
import type { Transformer } from './index';
import type { Transformer } from './transformer';
import {
replaceGitHubUrlType,
replaceGithubUrlType,
ScmIntegrationRegistry,
} from '@backstage/integration';
import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
@@ -59,7 +59,7 @@ export const addGitFeedbackLink = (
// Convert GitHub edit url to blob type so it can be parsed by git-url-parse correctly
const gitUrl =
integration?.type === 'github'
? replaceGitHubUrlType(sourceURL.href, 'blob')
? replaceGithubUrlType(sourceURL.href, 'blob')
: sourceURL.href;
const gitInfo = parseGitUrl(gitUrl);
const repoPath = `/${gitInfo.organization}/${gitInfo.name}`;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Transformer } from './index';
import type { Transformer } from './transformer';
import MenuIcon from '@material-ui/icons/Menu';
import React from 'react';
import ReactDOM from 'react-dom';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { scrollIntoNavigation } from '.';
import { scrollIntoNavigation } from './scrollIntoNavigation';
import { createTestShadowDom, FIXTURES } from '../../test-utils';
jest.useFakeTimers();