diff --git a/.changeset/spotty-ducks-exercise.md b/.changeset/spotty-ducks-exercise.md new file mode 100644 index 0000000000..be1ae987cb --- /dev/null +++ b/.changeset/spotty-ducks-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +replaceGitLabUrlType exported from package diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index cdb260f089..190278c99f 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -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; diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 01e42e0984..575ce75308 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -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', ), diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index cb24829946..0c52799599 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -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 { diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 950205d61c..e8d6665a6f 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -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'; diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index 0feb4dd7a4..0aeacde75c 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -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; + +// @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; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 43aba14f2f..9813bdc66f 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -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", diff --git a/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx new file mode 100644 index 0000000000..a3af0a09f0 --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.test.tsx @@ -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( + + + , + ), + ); + + 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( + + + , + ), + ); + + fireEvent.click(screen.getByText(/Open new Github issue/)); + + await waitFor(() => { + expect(apiSpy.getEvents()[0]).toMatchObject({ + action: 'click', + subject: 'Open new Github issue', + }); + }); + }); +}); diff --git a/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx new file mode 100644 index 0000000000..51cff1324c --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/FeedbackLink.tsx @@ -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 ( + + Open new {getName(repository)} issue + + ); +}; diff --git a/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx b/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx new file mode 100644 index 0000000000..e6da14a424 --- /dev/null +++ b/plugins/techdocs/src/addons/GiveFeedback/GiveFeedback.tsx @@ -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