Merge pull request #10882 from realandersn/techdocs/report-issue-addon

[TechDocs] ReportIssue addon
This commit is contained in:
Eric Peterson
2022-04-25 15:02:56 +02:00
committed by GitHub
17 changed files with 716 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-techdocs-module-addons-contrib': minor
---
A package for contributed TechDocs addons.
In this release it will introduce the ReportIssue addon, which lets you select text and open a GitHub/Gitlab issue.
+2
View File
@@ -55,6 +55,8 @@
"@backstage/plugin-stack-overflow": "^0.1.0",
"@backstage/plugin-tech-radar": "^0.5.11",
"@backstage/plugin-techdocs": "^1.1.0",
"@backstage/plugin-techdocs-react": "^0.1.0",
"@backstage/plugin-techdocs-module-addons-contrib": "^0.0.0",
"@backstage/plugin-todo": "^0.2.6",
"@backstage/plugin-user-settings": "^0.4.3",
"@backstage/plugin-tech-insights": "^0.2.0",
+5
View File
@@ -69,6 +69,8 @@ import {
TechDocsReaderPage,
techdocsPlugin,
} from '@backstage/plugin-techdocs';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib';
import {
UserSettingsPage,
UserSettingsTab,
@@ -179,6 +181,9 @@ const routes = (
element={<TechDocsReaderPage />}
>
{techDocsPage}
<TechDocsAddons>
<ReportIssue />
</TechDocsAddons>
</Route>
<Route
path="/create"
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,35 @@
## API Report File for "@backstage/plugin-techdocs-module-addons-contrib"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
// @public
export const ReportIssue: ComponentType<ReportIssueProps>;
// @public (undocumented)
export type ReportIssueProps = {
debounceTime?: number;
templateBuilder?: ReportIssueTemplateBuilder;
};
// @public (undocumented)
export type ReportIssueTemplate = {
title: string;
body: string;
};
// @public (undocumented)
export type ReportIssueTemplateBuilder = ({
selection,
}: {
selection: Selection;
}) => ReportIssueTemplate;
// @public
export const techdocsModuleAddonsContribPlugin: BackstagePlugin<{}, {}>;
```
@@ -0,0 +1,69 @@
{
"name": "@backstage/plugin-techdocs-module-addons-contrib",
"description": "Plugin module for contributed TechDocs Addons",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/techdocs-module-addons-contrib"
},
"keywords": [
"backstage",
"techdocs"
],
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/core-components": "^0.9.3",
"@backstage/core-plugin-api": "^1.0.1",
"@backstage/integration": "^1.1.0",
"@backstage/integration-react": "^1.0.1",
"@backstage/plugin-techdocs-react": "^0.1.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"git-url-parse": "^11.6.0",
"react-use": "^17.2.4"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.17.0",
"@backstage/core-app-api": "^1.0.1",
"@backstage/dev-utils": "^1.0.1",
"@backstage/test-utils": "^1.0.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"msw": "^0.35.0",
"cross-fetch": "^3.1.5"
},
"files": [
"dist"
]
}
@@ -0,0 +1,84 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { analyticsApiRef } from '@backstage/core-plugin-api';
import {
MockAnalyticsApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';
import { IssueLink } from './IssueLink';
const defaultProps = {
repository: {
type: 'github',
name: 'backstage',
owner: 'backstage',
protocol: 'https',
resource: 'github.com',
},
template: {
title: 'Documentation feedback',
body: '## Documentation Feedback 📝',
},
};
describe('FeedbackLink', () => {
const apiSpy = new MockAnalyticsApi();
it('Should open new issue tab', () => {
render(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, apiSpy]]}>
<IssueLink {...defaultProps} />
</TestApiProvider>,
),
);
const link = screen.getByText(/Open new Github issue/);
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('target', '_blank');
const encodedTitle = encodeURIComponent(defaultProps.template.title);
const encodedBody = encodeURIComponent(defaultProps.template.body);
expect(link).toHaveAttribute(
'href',
`https://github.com/backstage/backstage/issues/new?title=${encodedTitle}&body=${encodedBody}`,
);
});
it('Should track click events', async () => {
render(
wrapInTestApp(
<TestApiProvider apis={[[analyticsApiRef, apiSpy]]}>
<IssueLink {...defaultProps} />
</TestApiProvider>,
),
);
fireEvent.click(screen.getByText(/Open new Github issue/));
await waitFor(() => {
expect(apiSpy.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'Open new Github issue',
});
});
});
});
@@ -0,0 +1,83 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core';
import BugReportIcon from '@material-ui/icons/BugReport';
import { Link, GitHubIcon } from '@backstage/core-components';
import { ReportIssueTemplate, Repository } from './types';
const useStyles = makeStyles(theme => ({
root: {
display: 'grid',
gridGap: theme.spacing(1),
gridAutoFlow: 'column',
justifyContent: 'center',
alignItems: 'center',
color: theme.palette.common.black,
fontSize: theme.typography.button.fontSize,
},
}));
type IssueLinkProps = {
template: ReportIssueTemplate;
repository: Repository;
};
const getIcon = ({ type }: Repository) => {
if (type === 'github') {
return GitHubIcon;
}
return BugReportIcon;
};
const getName = ({ type }: Repository) => {
if (type === 'github') {
return 'Github';
}
return 'Gitlab';
};
const getUrl = (repository: Repository, template: ReportIssueTemplate) => {
const { title, body } = template;
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
const { protocol, resource, owner, name, type } = repository;
const encodedOwner = encodeURIComponent(owner);
const encodedName = encodeURIComponent(name);
const url = `${protocol}://${resource}/${encodedOwner}/${encodedName}`;
if (type === 'github') {
return `${url}/issues/new?title=${encodedTitle}&body=${encodedBody}`;
}
return `${url}/issues/new?[title]=${encodedTitle}&[body]=${encodedBody}`;
};
export const IssueLink = ({ template, repository }: IssueLinkProps) => {
const classes = useStyles();
const Icon = getIcon(repository);
const url = getUrl(repository, template);
return (
<Link className={classes.root} to={url} target="_blank">
<Icon /> Open new {getName(repository)} issue
</Link>
);
};
@@ -0,0 +1,134 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { makeStyles, Portal, Paper } from '@material-ui/core';
import { useGitTemplate, useGitRepository } from './hooks';
import { ReportIssueTemplateBuilder } from './types';
import {
PAGE_MAIN_CONTENT_SELECTOR,
PAGE_FEEDBACK_LINK_SELECTOR,
ADDON_FEEDBACK_CONTAINER_ID,
ADDON_FEEDBACK_CONTAINER_SELECTOR,
} from './constants';
import { IssueLink } from './IssueLink';
import {
useShadowRootElements,
useShadowRootSelection,
} from '@backstage/plugin-techdocs-react';
const useStyles = makeStyles(theme => ({
root: {
transform: 'translate(-100%, -100%)',
position: 'absolute',
padding: theme.spacing(1),
zIndex: theme.zIndex.tooltip,
background: theme.palette.common.white,
},
}));
type Style = {
top: string;
left: string;
};
/**
* @public
*/
export type ReportIssueProps = {
debounceTime?: number;
templateBuilder?: ReportIssueTemplateBuilder;
};
/**
* Show report issue button when text is highlighted
*/
export const ReportIssueAddon = ({
debounceTime = 500,
templateBuilder: buildTemplate,
}: ReportIssueProps) => {
const classes = useStyles();
const [style, setStyle] = useState<Style>();
const repository = useGitRepository();
const defaultTemplate = useGitTemplate(debounceTime);
const selection = useShadowRootSelection(debounceTime);
const [mainContent, feedbackLink] = useShadowRootElements([
PAGE_MAIN_CONTENT_SELECTOR,
PAGE_FEEDBACK_LINK_SELECTOR,
]);
let [feedbackContainer] = useShadowRootElements([
ADDON_FEEDBACK_CONTAINER_SELECTOR,
]);
if (feedbackLink) {
feedbackLink.style.display = 'none';
}
// calculates the position of the selected text to be able to set the position of the addon
useEffect(() => {
if (
// todo(backstage/techdocs-core) handle non-repo rendering
!repository ||
!selection ||
!selection.containsNode(mainContent!, true) ||
selection?.containsNode(feedbackContainer!, true)
) {
return;
}
const mainContentPosition = mainContent!.getBoundingClientRect();
const selectionPosition = selection.getRangeAt(0).getBoundingClientRect();
setStyle({
top: `${selectionPosition.top - mainContentPosition.top - 16}px`,
left: `${selectionPosition.left + selectionPosition.width / 2}px`,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, mainContent, feedbackContainer]);
if (!selection || !repository) return null;
if (!feedbackContainer) {
feedbackContainer = document.createElement('div');
feedbackContainer.setAttribute('id', ADDON_FEEDBACK_CONTAINER_ID);
mainContent!.prepend(feedbackContainer);
}
return (
<Portal container={feedbackContainer}>
<Paper
data-testid="report-issue-addon"
className={classes.root}
style={style}
>
<IssueLink
repository={repository}
template={
buildTemplate ? buildTemplate({ selection }) : defaultTemplate
}
/>
</Paper>
</Portal>
);
};
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const ADDON_FEEDBACK_CONTAINER_ID = 'techdocs-report-issue';
export const ADDON_FEEDBACK_CONTAINER_SELECTOR = `#${ADDON_FEEDBACK_CONTAINER_ID}`;
export const PAGE_EDIT_LINK_SELECTOR = '[title^="Edit this page"]';
export const PAGE_FEEDBACK_LINK_SELECTOR = '[title^="Leave feedback for"]';
export const PAGE_MAIN_CONTENT_SELECTOR =
'[data-md-component="main"] .md-content';
@@ -0,0 +1,102 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { useApi } from '@backstage/core-plugin-api';
import {
replaceGitHubUrlType,
replaceGitLabUrlType,
} from '@backstage/integration';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import {
useShadowRootElements,
useShadowRootSelection,
} from '@backstage/plugin-techdocs-react';
import { PAGE_EDIT_LINK_SELECTOR } from './constants';
const resolveBlobUrl = (url: string, type: string) => {
if (type === 'github') {
return replaceGitHubUrlType(url, 'blob');
} else if (type === 'gitlab') {
return replaceGitLabUrlType(url, 'blob');
}
// eslint-disable-next-line no-console
console.error(
`Invalid SCM type ${type} found in ReportIssue addon for URL ${url}!`,
);
return url;
};
export const getTitle = (selection: Selection) => {
const text = selection.toString().substring(0, 70);
const ellipsis = text.length === 70 ? '...' : '';
return `Documentation feedback: ${text}${ellipsis}`;
};
export const getBody = (selection: Selection, markdownUrl: string) => {
const title = '## Documentation Feedback 📝';
const subheading = '#### The highlighted text:';
const commentHeading = '#### The comment on the text:';
const commentPlaceholder = '_>replace this line with your comment<_';
const highlightedTextAsQuote = selection
.toString()
.trim()
.split('\n')
.map(line => `> ${line.trim()}`)
.join('\n');
const facts = [
`Backstage URL: <${window.location.href}> \nMarkdown URL: <${markdownUrl}>`,
];
return `${title}\n\n ${subheading} \n\n ${highlightedTextAsQuote}\n\n ${commentHeading} \n ${commentPlaceholder}\n\n ___\n${facts}`;
};
export const useGitTemplate = (debounceTime?: number) => {
const initialTemplate = { title: '', body: '' };
const selection = useShadowRootSelection(debounceTime);
const [editLink] = useShadowRootElements([PAGE_EDIT_LINK_SELECTOR]);
const url = (editLink as HTMLAnchorElement)?.href ?? '';
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
if (!selection || !url) return initialTemplate;
const type = scmIntegrationsApi.byUrl(url)?.type;
if (!type) return initialTemplate;
return {
title: getTitle(selection),
body: getBody(selection, resolveBlobUrl(url, type)),
};
};
export const useGitRepository = () => {
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const [editLink] = useShadowRootElements([PAGE_EDIT_LINK_SELECTOR]);
const url = (editLink as HTMLAnchorElement)?.href ?? '';
if (!url) return null;
const type = scmIntegrationsApi.byUrl(url)?.type;
if (!type) return null;
return { ...parseGitUrl(resolveBlobUrl(url, type)), type };
};
@@ -0,0 +1,19 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ReportIssueAddon } from './ReportIssue';
export type { ReportIssueProps } from './ReportIssue';
export type { ReportIssueTemplate, ReportIssueTemplateBuilder } from './types';
@@ -0,0 +1,40 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @public
*/
export type ReportIssueTemplate = {
title: string;
body: string;
};
/**
* @public
*/
export type ReportIssueTemplateBuilder = ({
selection,
}: {
selection: Selection;
}) => ReportIssueTemplate;
export type Repository = {
type: string;
name: string;
owner: string;
protocol: string;
resource: string;
};
@@ -0,0 +1,28 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A plugin for contributed TechDocs Addons.
*
* @packageDocumentation
*/
export { techdocsModuleAddonsContribPlugin, ReportIssue } from './plugin';
export type {
ReportIssueProps,
ReportIssueTemplate,
ReportIssueTemplateBuilder,
} from './ReportIssue';
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { techdocsModuleAddonsContribPlugin } from './plugin';
describe('techdocs-addons-contrib', () => {
it('should export plugin', () => {
expect(techdocsModuleAddonsContribPlugin).toBeDefined();
});
});
@@ -0,0 +1,46 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core-plugin-api';
import {
createTechDocsAddonExtension,
TechDocsAddonLocations,
} from '@backstage/plugin-techdocs-react';
import { ReportIssueAddon, ReportIssueProps } from './ReportIssue';
/**
* The TechDocs addons contrib plugin
*
* @public
*/
export const techdocsModuleAddonsContribPlugin = createPlugin({
id: 'techdocsModuleAddonsContrib',
});
/**
* TechDocs addon that lets you select text and open GitHub/Gitlab issues
*
* @public
*/
export const ReportIssue = techdocsModuleAddonsContribPlugin.provide(
createTechDocsAddonExtension<ReportIssueProps>({
name: 'ReportIssue',
location: TechDocsAddonLocations.Content,
component: ReportIssueAddon,
}),
);
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';