fix(techdocs): prevent to throw an error when the SCM provider is not supported (#29154)

* fix(techdocs): prevent to throw error on undefined 'selection'

Error
TypeError

Message
Failed to execute 'containsNode' on 'Selection': parameter 1 is not of type 'Node'.

Stack Trace
TypeError: parameter 1 is not of type 'Node'.
    at https://backstage.mpi-internal.com/static/module-backstage.b2517ed5.js:618:1729
    at ul (https://backstage.mpi-internal.com/static/module-react-dom.b4fe5cf3.js:16:24313)
    at Ct (https://backstage.mpi-internal.com/static/module-react-dom.b4fe5cf3.js:16:42455)
    at qs (https://backstage.mpi-internal.com/static/module-react-dom.b4fe5cf3.js:16:34578)
    at Ln (https://backstage.mpi-internal.com/static/module-react-dom.b4fe5cf3.js:24:1590)
    at MessagePort.Zn (https://backstage.mpi-internal.com/static/module-react-dom.b4fe5cf3.js:24:1980)

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>

* doc: add changeset

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>

* chore(techinsights-addon): doesn't load the component for unsuported repositories

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>

* Update plugins/techdocs-module-addons-contrib/src/ReportIssue/ReportIssueContent.tsx

Co-authored-by: Thomas Cardonne <thomas.cardonne@adevinta.com>
Signed-off-by: Andy Ladjadj <andy.ladjadj@adevinta.com>

* Update plugins/techdocs-module-addons-contrib/src/ReportIssue/ReportIssueContent.tsx

Co-authored-by: Thomas Cardonne <thomas.cardonne@adevinta.com>
Signed-off-by: Andy Ladjadj <andy.ladjadj@adevinta.com>

* fix: doesn't test that selection contains feebackContainer

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>

* ci: update the changeset

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>

---------

Signed-off-by: Andy LADJADJ <andy.ladjadj@adevinta.com>
Signed-off-by: Andy Ladjadj <andy.ladjadj@adevinta.com>
Co-authored-by: Thomas Cardonne <thomas.cardonne@adevinta.com>
This commit is contained in:
Andy Ladjadj
2025-03-27 00:40:26 +01:00
committed by GitHub
parent a31a430c17
commit 9c12a76c9e
6 changed files with 203 additions and 123 deletions
@@ -48,10 +48,7 @@ const getIcon = ({ type }: Repository) => {
};
const getName = ({ type }: Repository) => {
if (type === 'github') {
return 'Github';
}
return 'Gitlab';
return type.charAt(0).toLocaleUpperCase('en-US') + type.slice(1);
};
const getUrl = (repository: Repository, template: ReportIssueTemplate) => {
@@ -116,7 +116,7 @@ describe('ReportIssue', () => {
it('renders gitlab link without exploding', async () => {
byUrl.mockReturnValue({ type: 'gitlab' });
const { shadowRoot, getByText } =
const { shadowRoot, getByText, queryByTestId } =
await TechDocsAddonTester.buildAddonsInTechDocs([
<ReportIssue debounceTime={0} />,
])
@@ -164,6 +164,8 @@ describe('ReportIssue', () => {
fireSelectionChangeEvent(window);
await waitFor(() => {
expect(queryByTestId('report-issue-addon')).toBeInTheDocument();
const link = getByText('Open new Gitlab issue');
expect(link).toHaveAttribute(
'href',
@@ -180,7 +182,7 @@ describe('ReportIssue', () => {
body: options.selection.toString().trim(),
});
const { shadowRoot, getByText } =
const { shadowRoot, getByText, queryByTestId } =
await TechDocsAddonTester.buildAddonsInTechDocs([
<ReportIssue debounceTime={0} templateBuilder={templateBuilder} />,
])
@@ -228,6 +230,8 @@ describe('ReportIssue', () => {
fireSelectionChangeEvent(window);
await waitFor(() => {
expect(queryByTestId('report-issue-addon')).toBeInTheDocument();
const link = getByText('Open new Gitlab issue');
expect(link).toHaveAttribute(
'href',
@@ -235,4 +239,49 @@ describe('ReportIssue', () => {
);
});
});
it('does not render report issue link for unsupported repository type', async () => {
byUrl.mockReturnValue({ type: 'gerrit', resource: 'gerrit.example.com' });
const { shadowRoot, getByText, queryByTestId } =
await TechDocsAddonTester.buildAddonsInTechDocs([
<ReportIssue debounceTime={0} />,
])
.withDom(
<html lang="en">
<head />
<body>
<div data-md-component="content">
<div data-md-component="main">
<div className="md-content">
<article>
<a
title="Edit this page"
href="https://gerrit.example.com/admin/repos/edit/repo/my/repo/branch/refs/heads/master/file/docs/README.md"
>
Edit page
</a>
</article>
</div>
</div>
</div>
</body>
</html>,
)
.withApis([[scmIntegrationsApiRef, { byUrl }]])
.renderWithEffects();
(shadowRoot as ShadowRoot & Pick<Document, 'getSelection'>).getSelection =
() => selection;
await waitFor(() => {
expect(getByText('Edit page')).toBeInTheDocument();
});
fireSelectionChangeEvent(window);
await waitFor(() => {
expect(queryByTestId('report-issue-addon')).not.toBeInTheDocument();
});
});
});
@@ -14,39 +14,11 @@
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { makeStyles, Portal, Paper } from '@material-ui/core';
import { useGitTemplate, useGitRepository } from './hooks';
import React from 'react';
import { 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;
};
import { ADDON_ISSUE_REPO_TYPES_SUPPORTED } from './constants';
import { ReportIssueAddonContent } from './ReportIssueContent';
/**
* Props customizing the <ReportIssue /> Addon.
@@ -67,101 +39,24 @@ export type ReportIssueProps = {
templateBuilder?: ReportIssueTemplateBuilder;
};
/**
* Show report issue button when text is highlighted
*/
export const ReportIssueAddon = ({
debounceTime = 500,
templateBuilder: buildTemplate,
templateBuilder,
}: 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();
// Calculating the distance between the selection's top and the main content's top
const distanceFromTop = selectionPosition.top - mainContentPosition.top;
const minDistanceFromTop = 50;
// Defining a base value for 'top'
let top = distanceFromTop < minDistanceFromTop ? 101 : distanceFromTop - 16;
// Checking if the main content is off-screen towards the top
if (mainContentPosition.top < 0) {
const absMainContentTop = Math.abs(mainContentPosition.top);
// Adjusting 'top' if the selection is close to the top edge and the main content is off-screen
if (distanceFromTop - absMainContentTop < minDistanceFromTop) {
top += 89;
}
}
setStyle({
top: `${top}px`,
left: `${selectionPosition.left + selectionPosition.width / 2}px`,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, mainContent, feedbackContainer]);
if (
!selection ||
!repository ||
!['github', 'gitlab'].includes(repository.type)
)
!ADDON_ISSUE_REPO_TYPES_SUPPORTED.includes(repository.type)
) {
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>
<ReportIssueAddonContent
debounceTime={debounceTime}
templateBuilder={templateBuilder}
repository={repository}
/>
);
};
@@ -0,0 +1,133 @@
/*
* 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 } from './hooks';
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';
import { Repository } from './types';
import { ReportIssueProps } from './ReportIssue';
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;
};
// Props customizing the <ReportIssue /> Addon.
export type ReportIssueAddonContentProps = ReportIssueProps & {
repository: Repository;
};
export const ReportIssueAddonContent = ({
debounceTime,
templateBuilder,
repository,
}: ReportIssueAddonContentProps) => {
const classes = useStyles();
const [style, setStyle] = useState<Style>();
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';
}
useEffect(() => {
if (
!selection ||
!selection.containsNode(mainContent, true) ||
selection.containsNode(feedbackContainer, true)
) {
return;
}
const mainContentPosition = mainContent.getBoundingClientRect();
const selectionPosition = selection.getRangeAt(0).getBoundingClientRect();
const distanceFromTop = selectionPosition.top - mainContentPosition.top;
const minDistanceFromTop = 50;
let top = distanceFromTop < minDistanceFromTop ? 101 : distanceFromTop - 16;
if (mainContentPosition.top < 0) {
const absMainContentTop = Math.abs(mainContentPosition.top);
if (distanceFromTop - absMainContentTop < minDistanceFromTop) {
top += 89;
}
}
setStyle({
top: `${top}px`,
left: `${selectionPosition.left + selectionPosition.width / 2}px`,
});
}, [selection, mainContent, feedbackContainer]);
if (!feedbackContainer) {
feedbackContainer = document.createElement('div');
feedbackContainer.setAttribute('id', ADDON_FEEDBACK_CONTAINER_ID);
mainContent!.prepend(feedbackContainer);
}
if (!selection || !selection.containsNode(mainContent, true)) {
return null;
}
return (
<Portal container={feedbackContainer}>
<Paper
data-testid="report-issue-addon"
className={classes.root}
style={style}
>
<IssueLink
repository={repository}
template={
templateBuilder ? templateBuilder({ selection }) : defaultTemplate
}
/>
</Paper>
</Portal>
);
};
@@ -16,6 +16,7 @@
export const ADDON_FEEDBACK_CONTAINER_ID = 'techdocs-report-issue';
export const ADDON_FEEDBACK_CONTAINER_SELECTOR = `#${ADDON_FEEDBACK_CONTAINER_ID}`;
export const ADDON_ISSUE_REPO_TYPES_SUPPORTED = ['github', 'gitlab'];
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 =