[TechDocs Addons] Give Feedback Addon (#10733)
* give feedback addon implementation Co-authored-by: Camila Belo <camilaibs@gmail.com> Co-authored-by: Anders Näsman <andersn@spotify.com> Signed-off-by: Emma Indal <emma.indahl@gmail.com> * export replaceUrlType from @backstage/integration-reacte Co-authored-by: Camila Belo <camilaibs@gmail.com> Co-authored-by: Anders Näsman <andersn@spotify.com> Signed-off-by: Emma Indal <emma.indahl@gmail.com> * export give feedback addon Signed-off-by: Emma Indal <emma.indahl@gmail.com> * add hooks used for give feedback addon Co-authored-by: Camila Belo <camilaibs@gmail.com> Co-authored-by: Anders Näsman <andersn@spotify.com> Signed-off-by: Emma Indal <emma.indahl@gmail.com> * replaceUrlType -> replaceGitLabUrlType Signed-off-by: Emma Indal <emma.indahl@gmail.com> * clarify template + template builder types Signed-off-by: Emma Indal <emma.indahl@gmail.com> * feedback fixups Signed-off-by: Emma Indal <emma.indahl@gmail.com> Co-authored-by: Camila Belo <camilaibs@gmail.com> Co-authored-by: Anders Näsman <andersn@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
replaceGitLabUrlType exported from package
|
||||
@@ -416,6 +416,12 @@ export function replaceGitHubUrlType(
|
||||
type: 'blob' | 'tree' | 'edit',
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export function replaceGitLabUrlType(
|
||||
url: string,
|
||||
type: 'blob' | 'tree' | 'edit',
|
||||
): string;
|
||||
|
||||
// @public
|
||||
export interface ScmIntegration {
|
||||
resolveEditUrl(url: string): string;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
|
||||
import { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
|
||||
|
||||
describe('GitLabIntegration', () => {
|
||||
it('has a working factory', () => {
|
||||
@@ -55,28 +55,28 @@ describe('GitLabIntegration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceUrlType', () => {
|
||||
describe('replaceGitLabUrlType', () => {
|
||||
it('should replace with expected type', () => {
|
||||
expect(
|
||||
replaceUrlType(
|
||||
replaceGitLabUrlType(
|
||||
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
|
||||
'edit',
|
||||
),
|
||||
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
replaceGitLabUrlType(
|
||||
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
replaceGitLabUrlType(
|
||||
'https://gitlab.com/blob/blob/-/blob/develop/test',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
replaceGitLabUrlType(
|
||||
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
|
||||
'tree',
|
||||
),
|
||||
|
||||
@@ -60,11 +60,18 @@ export class GitLabIntegration implements ScmIntegration {
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
return replaceUrlType(url, 'edit');
|
||||
return replaceGitLabUrlType(url, 'edit');
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceUrlType(
|
||||
/**
|
||||
* Takes a GitLab URL and replaces the type part (blob, tree etc).
|
||||
*
|
||||
* @param url - The original URL
|
||||
* @param type - The desired type, e.g. 'blob', 'tree', 'edit'
|
||||
* @public
|
||||
*/
|
||||
export function replaceGitLabUrlType(
|
||||
url: string,
|
||||
type: 'blob' | 'tree' | 'edit',
|
||||
): string {
|
||||
|
||||
@@ -20,4 +20,4 @@ export {
|
||||
} from './config';
|
||||
export type { GitLabIntegrationConfig } from './config';
|
||||
export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core';
|
||||
export { GitLabIntegration } from './GitLabIntegration';
|
||||
export { GitLabIntegration, replaceGitLabUrlType } from './GitLabIntegration';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { ComponentType } from 'react';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CSSProperties } from '@material-ui/styles';
|
||||
@@ -154,6 +155,26 @@ export const EntityTechdocsContent: (props: {
|
||||
children?: ReactNode;
|
||||
}) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const GiveFeedbackAddon: ComponentType<GiveFeedbackProps>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type GiveFeedbackProps = {
|
||||
debounceTime?: number;
|
||||
templateBuilder?: GiveFeedbackTemplateBuilder;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type GiveFeedbackTemplate = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type GiveFeedbackTemplateBuilder = (
|
||||
selection: Selection,
|
||||
) => GiveFeedbackTemplate;
|
||||
|
||||
// @public
|
||||
export const isTechDocsAvailable: (entity: Entity) => boolean;
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@material-ui/styles": "^4.10.0",
|
||||
"dompurify": "^2.2.9",
|
||||
"event-source-polyfill": "1.0.25",
|
||||
"event-source-polyfill": "^1.0.25",
|
||||
"git-url-parse": "^11.6.0",
|
||||
"jss": "~10.8.2",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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 { FeedbackLink } from './FeedbackLink';
|
||||
|
||||
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]]}>
|
||||
<FeedbackLink {...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]]}>
|
||||
<FeedbackLink {...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 { GiveFeedbackTemplate, 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 FeedbackLinkProps = {
|
||||
template: GiveFeedbackTemplate;
|
||||
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: GiveFeedbackTemplate) => {
|
||||
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 FeedbackLink = ({ template, repository }: FeedbackLinkProps) => {
|
||||
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,132 @@
|
||||
/*
|
||||
* 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 { GiveFeedbackTemplateBuilder } from './types';
|
||||
import {
|
||||
PAGE_MAIN_CONTENT_SELECTOR,
|
||||
PAGE_FEEDBACK_LINK_SELECTOR,
|
||||
ADDON_FEEDBACK_CONTAINER_ID,
|
||||
ADDON_FEEDBACK_CONTAINER_SELECTOR,
|
||||
} from './constants';
|
||||
import { FeedbackLink } from './FeedbackLink';
|
||||
|
||||
import {
|
||||
useShadowRootElements,
|
||||
useShadowRootSelection,
|
||||
} from '../../reader/components/TechDocsReaderPage';
|
||||
|
||||
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 GiveFeedbackProps = {
|
||||
debounceTime?: number;
|
||||
templateBuilder?: GiveFeedbackTemplateBuilder;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show give feedback button when text is highlighted
|
||||
*/
|
||||
export const GiveFeedback = ({
|
||||
debounceTime = 500,
|
||||
templateBuilder: buildTemplate,
|
||||
}: GiveFeedbackProps) => {
|
||||
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="give-feedback-addon"
|
||||
className={classes.root}
|
||||
style={style}
|
||||
>
|
||||
<FeedbackLink
|
||||
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-give-feedback';
|
||||
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,96 @@
|
||||
/*
|
||||
* 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 '../../reader/components/TechDocsReaderPage';
|
||||
|
||||
import { PAGE_EDIT_LINK_SELECTOR } from './constants';
|
||||
|
||||
const resolveBlobUrl = (url: string, type: string) => {
|
||||
if (type === 'github') {
|
||||
return replaceGitHubUrlType(url, 'blob');
|
||||
}
|
||||
return replaceGitLabUrlType(url, 'blob');
|
||||
};
|
||||
|
||||
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,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 { GiveFeedback } from './GiveFeedback';
|
||||
export type { GiveFeedbackProps } from './GiveFeedback';
|
||||
export type {
|
||||
GiveFeedbackTemplate,
|
||||
GiveFeedbackTemplateBuilder,
|
||||
} from './types';
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 GiveFeedbackTemplate = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type GiveFeedbackTemplateBuilder = (
|
||||
selection: Selection,
|
||||
) => GiveFeedbackTemplate;
|
||||
|
||||
export type Repository = {
|
||||
type: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
protocol: string;
|
||||
resource: string;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 type {
|
||||
GiveFeedbackProps,
|
||||
GiveFeedbackTemplate,
|
||||
GiveFeedbackTemplateBuilder,
|
||||
} from './GiveFeedback';
|
||||
@@ -24,6 +24,7 @@ export * from './types';
|
||||
export * from './api';
|
||||
export * from './client';
|
||||
export * from './reader';
|
||||
export * from './addons';
|
||||
export * from './search';
|
||||
export * from './home';
|
||||
export {
|
||||
@@ -34,5 +35,6 @@ export {
|
||||
TechDocsReaderPage,
|
||||
techdocsPlugin as plugin,
|
||||
techdocsPlugin,
|
||||
GiveFeedbackAddon,
|
||||
} from './plugin';
|
||||
export * from './Router';
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
fetchApiRef,
|
||||
identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { GiveFeedback, GiveFeedbackProps } from './addons/GiveFeedback';
|
||||
import {
|
||||
createTechDocsAddon,
|
||||
TechDocsAddonLocations,
|
||||
} from '@backstage/techdocs-addons';
|
||||
|
||||
/**
|
||||
* The Backstage plugin that renders technical documentation for your components
|
||||
@@ -150,3 +155,17 @@ export const TechDocsReaderPage = techdocsPlugin.provide(
|
||||
mountPoint: rootDocsRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* TechDocs addon that lets you select text and open GitHub/Gitlab issues
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
export const GiveFeedbackAddon = techdocsPlugin.provide(
|
||||
createTechDocsAddon<GiveFeedbackProps>({
|
||||
name: 'GiveFeedback',
|
||||
location: TechDocsAddonLocations.CONTENT,
|
||||
component: GiveFeedback,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,13 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useShadowRoot, useShadowRootElements } from './hooks';
|
||||
import {
|
||||
useShadowRoot,
|
||||
useShadowRootElements,
|
||||
useShadowRootSelection,
|
||||
} from './hooks';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const fireSelectionChangeEvent = (window: Window) => {
|
||||
const selectionChangeEvent = window.document.createEvent('Event');
|
||||
selectionChangeEvent.initEvent('selectionchange', true, true);
|
||||
window.document.addEventListener('selectionchange', () => {}, false);
|
||||
fireEvent(window.document, selectionChangeEvent);
|
||||
};
|
||||
|
||||
const getSelection = jest.fn();
|
||||
|
||||
const mockShadowRoot = () => {
|
||||
const div = document.createElement('div');
|
||||
const shadowRoot = div.attachShadow({ mode: 'open' });
|
||||
shadowRoot.innerHTML = '<h1>Shadow DOM Mock</h1>';
|
||||
(shadowRoot as ShadowRoot & Pick<Document, 'getSelection'>).getSelection =
|
||||
getSelection;
|
||||
return shadowRoot;
|
||||
};
|
||||
|
||||
@@ -32,6 +48,28 @@ jest.mock('./context', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const selection = {
|
||||
type: 'Range',
|
||||
rangeCount: 1,
|
||||
isCollapsed: true,
|
||||
getRangeAt: () => ({
|
||||
startContainer: 'this is a sentence',
|
||||
endContainer: 'this is a sentence',
|
||||
startOffset: 1,
|
||||
endOffset: 3,
|
||||
getBoundingClientRect: () => ({
|
||||
right: 100,
|
||||
top: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
}),
|
||||
toString: () => 'his ',
|
||||
containsNode: () => true,
|
||||
} as unknown as Selection;
|
||||
|
||||
getSelection.mockReturnValue(selection);
|
||||
|
||||
describe('hooks', () => {
|
||||
describe('useShadowRoot', () => {
|
||||
it('should return shadow root', async () => {
|
||||
@@ -48,4 +86,18 @@ describe('hooks', () => {
|
||||
expect(result.current).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useShadowRootSelection', () => {
|
||||
it('should return shadow root selection', async () => {
|
||||
const { result } = renderHook(() => useShadowRootSelection(0));
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
fireSelectionChangeEvent(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current?.toString()).toEqual('his ');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useTechDocsReaderPage } from './context';
|
||||
|
||||
/**
|
||||
@@ -44,3 +46,50 @@ export const useShadowRootElements = <
|
||||
.map(nodeList => Array.from(nodeList))
|
||||
.flat();
|
||||
};
|
||||
|
||||
const isValidSelection = (newSelection: Selection) => {
|
||||
// Safari sets the selection rect to top zero
|
||||
return (
|
||||
newSelection.toString() &&
|
||||
newSelection.rangeCount &&
|
||||
newSelection.getRangeAt(0).getBoundingClientRect().top
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for retreiving a selection within the ShadowRoot.
|
||||
* @public
|
||||
*/
|
||||
export const useShadowRootSelection = (wait: number = 0) => {
|
||||
const shadowRoot = useShadowRoot();
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
const handleSelectionChange = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
const shadowDocument = shadowRoot as ShadowRoot &
|
||||
Pick<Document, 'getSelection'>;
|
||||
// Firefox and Safari don't implement getSelection for Shadow DOM
|
||||
const newSelection = shadowDocument.getSelection
|
||||
? shadowDocument.getSelection()
|
||||
: document.getSelection();
|
||||
|
||||
if (newSelection && isValidSelection(newSelection)) {
|
||||
setSelection(newSelection);
|
||||
} else {
|
||||
setSelection(null);
|
||||
}
|
||||
}, wait),
|
||||
[shadowRoot, setSelection, wait],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.document.addEventListener('selectionchange', handleSelectionChange);
|
||||
return () =>
|
||||
window.document.removeEventListener(
|
||||
'selectionchange',
|
||||
handleSelectionChange,
|
||||
);
|
||||
}, [handleSelectionChange]);
|
||||
|
||||
return selection;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user