From a48504427c0907284ce4010574d673f7aeac0e4a Mon Sep 17 00:00:00 2001 From: stanislavh Date: Tue, 13 Dec 2022 17:30:41 +0100 Subject: [PATCH] SONAR-17723 make links in tooltip to be interactive via keyboard --- .../api/mocks/SecurityHotspotServiceMock.ts | 39 +++++-- .../__tests__/SecurityHotspotsApp-it.tsx | 21 +++- .../components/assignee/AssigneeRenderer.tsx | 2 +- .../AssigneeRenderer-test.tsx.snap | 5 +- .../common/DocumentationTooltip.tsx | 64 +++++++--- .../__tests__/DocumentationTooltip-test.tsx | 81 ++++++++++--- .../DocumentationTooltip-test.tsx.snap | 110 ------------------ .../components/controls/FocusOutHandler.tsx | 20 +++- .../js/components/controls/HelpTooltip.tsx | 11 +- .../main/js/components/controls/Tooltip.tsx | 20 +++- .../__snapshots__/HelpTooltip-test.tsx.snap | 1 + 11 files changed, 213 insertions(+), 161 deletions(-) delete mode 100644 server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts index 3a28a222ae7..1526b1b2a30 100644 --- a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts @@ -22,31 +22,38 @@ import { mockHotspot, mockRawHotspot } from '../../helpers/mocks/security-hotspo import { mockSourceLine } from '../../helpers/mocks/sources'; import { mockRuleDetails } from '../../helpers/testMocks'; import { BranchParameters } from '../../types/branch-like'; -import { Hotspot, HotspotResolution, HotspotStatus } from '../../types/security-hotspots'; +import { + Hotspot, + HotspotAssignRequest, + HotspotResolution, + HotspotStatus, +} from '../../types/security-hotspots'; import { getSources } from '../components'; import { getMeasures } from '../measures'; import { getRuleDetails } from '../rules'; -import { getSecurityHotspotDetails, getSecurityHotspots } from '../security-hotspots'; +import { + assignSecurityHotspot, + getSecurityHotspotDetails, + getSecurityHotspots, +} from '../security-hotspots'; const NUMBER_OF_LINES = 20; const MAX_END_RANGE = 10; export default class SecurityHotspotServiceMock { - hotspots: Hotspot[]; - rawHotspotKey: string[]; + hotspots: Hotspot[] = []; + rawHotspotKey: string[] = []; + nextAssignee: string | undefined; constructor() { - this.rawHotspotKey = Object.keys(mockRawHotspot()); - this.hotspots = [ - mockHotspot({ key: '1', status: HotspotStatus.TO_REVIEW }), - mockHotspot({ key: '2', status: HotspotStatus.TO_REVIEW }), - ]; + this.reset(); (getMeasures as jest.Mock).mockImplementation(this.handleGetMeasures); (getSecurityHotspots as jest.Mock).mockImplementation(this.handleGetSecurityHotspots); (getSecurityHotspotDetails as jest.Mock).mockImplementation( this.handleGetSecurityHotspotDetails ); + (assignSecurityHotspot as jest.Mock).mockImplementation(this.handleAssignSecurityHotspot); (getRuleDetails as jest.Mock).mockResolvedValue({ rule: mockRuleDetails() }); (getSources as jest.Mock).mockResolvedValue( times(NUMBER_OF_LINES, (n) => @@ -105,6 +112,15 @@ export default class SecurityHotspotServiceMock { }); } + if (this.nextAssignee !== undefined) { + hotspot.assigneeUser = { + ...hotspot.assigneeUser, + login: this.nextAssignee, + name: this.nextAssignee, + }; + this.nextAssignee = undefined; + } + return this.reply(hotspot); }; @@ -121,6 +137,11 @@ export default class SecurityHotspotServiceMock { ]); }; + handleAssignSecurityHotspot = (_: string, data: HotspotAssignRequest) => { + this.nextAssignee = data.assignee; + return this.reply({}); + }; + reply(response: T): Promise { return Promise.resolve(cloneDeep(response)); } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx index 922b3deb7f7..3c6556ad138 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx @@ -21,7 +21,7 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Route } from 'react-router-dom'; -import { byRole, byTestId } from 'testing-library-selector'; +import { byRole, byTestId, byText } from 'testing-library-selector'; import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockLoggedInUser } from '../../../helpers/testMocks'; @@ -37,6 +37,12 @@ const ui = { selectStatusButton: byRole('button', { name: 'hotspots.status.select_status', }), + editAssigneeButton: byRole('button', { + name: 'hotspots.assignee.change_user', + }), + activeAssignee: byTestId('assignee-name'), + successGlobalMessage: byRole('status'), + currentUserSelectionItem: byText('foo'), panel: byTestId('security-hotspot-test'), }; @@ -50,6 +56,19 @@ afterEach(() => { handler.reset(); }); +it('should self-assign hotspot', async () => { + const user = userEvent.setup(); + renderSecurityHotspotsApp(); + + expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe'); + + await user.click(await ui.editAssigneeButton.find()); + await user.click(ui.currentUserSelectionItem.get()); + + expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`); + expect(ui.activeAssignee.get()).toHaveTextContent('foo'); +}); + it('should remember the comment when toggling change status panel for the same security hotspot', async () => { const user = userEvent.setup(); renderSecurityHotspotsApp(); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx index 0ea9123b509..9c181847ae9 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/AssigneeRenderer.tsx @@ -46,7 +46,7 @@ export default function AssigneeRenderer(props: AssigneeRendererProps) { {!editing && (
- + {assignee && (assignee.active ? assignee.name ?? assignee.login diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeRenderer-test.tsx.snap index ce932bdef41..6c98a977fd0 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/assignee/__tests__/__snapshots__/AssigneeRenderer-test.tsx.snap @@ -40,6 +40,7 @@ exports[`should render correctly: not editing 1`] = ` > user.x_deleted.Skywalker @@ -61,6 +62,7 @@ exports[`should render correctly: with active assignee 1`] = ` > John Doe @@ -82,6 +84,7 @@ exports[`should render correctly: without current assignee 1`] = ` > unassigned @@ -92,4 +95,4 @@ exports[`should render correctly: without current assignee 1`] = ` />
-`; +`; \ No newline at end of file diff --git a/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx b/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx index da2640af845..a58e79bdfb2 100644 --- a/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx +++ b/server/sonar-web/src/main/js/components/common/DocumentationTooltip.tsx @@ -17,8 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { first, last } from 'lodash'; import * as React from 'react'; import HelpTooltip from '../../components/controls/HelpTooltip'; +import { KeyboardKeys } from '../../helpers/keycodes'; import DocLink from './DocLink'; import Link from './Link'; @@ -31,11 +33,47 @@ export interface DocumentationTooltipProps { } export default function DocumentationTooltip(props: DocumentationTooltipProps) { - const { className, content, links, title } = props; + const nextSelectableNode = React.useRef(); + const linksRef = React.useRef<(HTMLAnchorElement | null)[]>([]); + const helpRef = React.useRef(null); + const { className, children, content, links, title } = props; + + function handleShowTooltip() { + document.addEventListener('keydown', handleTabPress); + } + + function handleHideTooltip() { + document.removeEventListener('keydown', handleTabPress); + nextSelectableNode.current = undefined; + } + + function handleTabPress(event: KeyboardEvent) { + if (event.code === KeyboardKeys.Tab) { + if (event.shiftKey === true) { + if (event.target === first(linksRef.current)) { + helpRef.current?.focus(); + } + return; + } + if (event.target === last(linksRef.current)) { + nextSelectableNode.current?.focus(); + return; + } + if (nextSelectableNode.current === undefined) { + nextSelectableNode.current = event.target as HTMLElement; + event.preventDefault(); + linksRef.current[0]?.focus(); + } + } + } return ( {title && ( @@ -50,20 +88,18 @@ export default function DocumentationTooltip(props: DocumentationTooltipProps) { <>
- {links.map(({ href, label, inPlace, doc = true }) => ( -
+ {links.map(({ href, label, inPlace, doc = true }, index) => ( +
{doc ? ( - {label} + (linksRef.current[index] = ref)}> + {label} + ) : ( - + (linksRef.current[index] = ref)} + target={inPlace ? undefined : '_blank'} + > {label} )} @@ -74,7 +110,7 @@ export default function DocumentationTooltip(props: DocumentationTooltipProps) {
} > - {props.children} + {children} ); } diff --git a/server/sonar-web/src/main/js/components/common/__tests__/DocumentationTooltip-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/DocumentationTooltip-test.tsx index 33bc5bf3ce3..fc14f419f25 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/DocumentationTooltip-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/DocumentationTooltip-test.tsx @@ -17,25 +17,74 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; + +import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { renderComponent } from '../../../helpers/testReactTestingUtils'; import DocumentationTooltip, { DocumentationTooltipProps } from '../DocumentationTooltip'; +import Link from '../Link'; + +const ui = { + body: byRole('body'), + beforeLink: byRole('link', { name: 'Interactive element before' }), + helpIcon: byTestId('help-tooltip-activator'), + linkInTooltip: byRole('link', { name: 'Label' }), + linkInTooltip2: byRole('link', { name: 'opens_in_new_window Label2' }), + afterLink: byRole('link', { name: 'Interactive element after' }), +}; + +it('should correctly navigate through TAB', async () => { + const user = userEvent.setup(); + renderDocumentationTooltip(); -it('renders correctly', () => { - expect(shallowRender()).toMatchSnapshot('basic'); - expect( - shallowRender({ - links: [ - { href: 'http://link.tosome.place', label: 'external link' }, - { href: '/guide', label: 'internal link' }, - { href: '/projects', label: 'in place', inPlace: true, doc: false }, - ], - }) - ).toMatchSnapshot('with links'); - expect(shallowRender({ title: undefined })).toMatchSnapshot('no title'); - expect(shallowRender({ content: undefined })).toMatchSnapshot('no content'); + await user.tab(); + expect(await ui.beforeLink.find()).toHaveFocus(); + await user.tab(); + expect(ui.helpIcon.get()).toHaveFocus(); + await user.tab(); + expect(ui.linkInTooltip.get()).toHaveFocus(); + await user.tab(); + expect(ui.linkInTooltip2.get()).toHaveFocus(); + // Looks like RTL tab event ignores any custom focuses during the events phase, + // unless preventDefault is specified + await user.tab(); + await user.tab({ shift: true }); + expect(await ui.afterLink.find()).toHaveFocus(); + await user.tab({ shift: true }); + await user.tab(); + await user.tab({ shift: true }); + expect(await ui.afterLink.find()).toHaveFocus(); }); -function shallowRender(props: Partial = {}) { - return shallow(); +function renderDocumentationTooltip(props: Partial = {}) { + return renderComponent( + <> + + Interactive element before + + + + Interactive element after + + + ); } diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap deleted file mode 100644 index b1ed2aba980..00000000000 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DocumentationTooltip-test.tsx.snap +++ /dev/null @@ -1,110 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly: basic 1`] = ` - -
- - title - -
-

- content -

-
- } -/> -`; - -exports[`renders correctly: no content 1`] = ` - -
- - title - -
- - } -/> -`; - -exports[`renders correctly: no title 1`] = ` - -

- content -

- - } -/> -`; - -exports[`renders correctly: with links 1`] = ` - -
- - title - -
-

- content -

- -
-
- - external link - -
-
- - internal link - -
-
- - in place - -
-
- - } -/> -`; diff --git a/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx b/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx index 8e428629328..2278277aa95 100644 --- a/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx +++ b/server/sonar-web/src/main/js/components/controls/FocusOutHandler.tsx @@ -19,12 +19,13 @@ */ import * as React from 'react'; -interface Props { +interface Props extends React.BaseHTMLAttributes { onFocusOut: () => void; + innerRef?: (instance: HTMLDivElement) => void; } export default class FocusOutHandler extends React.PureComponent> { - ref = React.createRef(); + ref?: HTMLDivElement; componentDidMount() { setTimeout(() => { @@ -36,13 +37,24 @@ export default class FocusOutHandler extends React.PureComponent { + const { innerRef } = this.props; + this.ref = node; + innerRef?.(node); + }; + handleFocusOut = () => { - if (this.ref.current && this.ref.current.querySelector(':focus') === null) { + if (this.ref?.querySelector(':focus') === null) { this.props.onFocusOut(); } }; render() { - return
{this.props.children}
; + const { onFocusOut, innerRef, children, ...props } = this.props; + return ( +
+ {children} +
+ ); } } diff --git a/server/sonar-web/src/main/js/components/controls/HelpTooltip.tsx b/server/sonar-web/src/main/js/components/controls/HelpTooltip.tsx index 523e21b7e6a..2c9af6703a3 100644 --- a/server/sonar-web/src/main/js/components/controls/HelpTooltip.tsx +++ b/server/sonar-web/src/main/js/components/controls/HelpTooltip.tsx @@ -29,10 +29,13 @@ interface Props extends Pick { className?: string; children?: React.ReactNode; onShow?: () => void; + onHide?: () => void; overlay: React.ReactNode; placement?: Placement; ariaLabel?: string; ariaLabelledby?: string; + isInteractive?: boolean; + innerRef?: React.Ref; } const DEFAULT_SIZE = 12; @@ -54,10 +57,16 @@ export default function HelpTooltip({ - + {props.children || } diff --git a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx index eafc1dc2099..f990f95b5ad 100644 --- a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx +++ b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx @@ -22,6 +22,7 @@ import * as React from 'react'; import { createPortal, findDOMNode } from 'react-dom'; import { rawSizes } from '../../app/theme'; import EscKeydownHandler from './EscKeydownHandler'; +import FocusOutHandler from './FocusOutHandler'; import ScreenPositionFixer from './ScreenPositionFixer'; import './Tooltip.css'; @@ -37,6 +38,10 @@ export interface TooltipProps { overlay: React.ReactNode; placement?: Placement; visible?: boolean; + // If tooltip overlay has interactive content (links for instance) we may set this to true to stop + // default behavior of tabbing (other changes should be done outside of this component to make it work) + // See example DocumentationTooltip + isInteractive?: boolean; } interface Measurements { @@ -286,6 +291,12 @@ export class TooltipInner extends React.Component { }; handleBlur = () => { + if (!this.props.isInteractive) { + this.closeTooltip(); + } + }; + + closeTooltip = () => { if (this.mounted) { this.setState({ visible: false }); if (this.props.onHide) { @@ -362,11 +373,12 @@ export class TooltipInner extends React.Component { : undefined; return ( -
@@ -380,7 +392,7 @@ export class TooltipInner extends React.Component { : undefined } /> -
+ ); }; @@ -402,7 +414,7 @@ export class TooltipInner extends React.Component { 'aria-labelledby': isVisible ? this.id : undefined, })} {isVisible && ( - + {this.renderActual} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap index bdc478b2d8e..e5d600bdd42 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap @@ -30,6 +30,7 @@ exports[`should render properly: default 1`] = ` >