Browse Source

SONAR-13622 Standard access to keyboard shortcuts

tags/8.5.0.37579
Jeremy Davis 3 years ago
parent
commit
1a69f5a96f

+ 1
- 14
server/sonar-docs/src/EmbedDocsSuggestions.json View File

@@ -12,10 +12,6 @@
{
"link": "/documentation/instance-administration/quality-profiles/",
"text": "Quality Profiles"
},
{
"link": "/documentation/user-guide/keyboard-shortcuts/",
"text": "Keyboard Shortcuts"
}
],
"component_measures": [
@@ -26,10 +22,6 @@
{
"link": "/documentation/user-guide/metric-definitions/",
"text": "Metric Definitions"
},
{
"link": "/documentation/user-guide/keyboard-shortcuts/",
"text": "Keyboard Shortcuts"
}
],
"custom_measures": [
@@ -58,12 +50,7 @@
"scope": "sonarcloud"
}
],
"issues": [
{
"link": "/documentation/user-guide/keyboard-shortcuts/",
"text": "Keyboard Shortcuts"
}
],
"issues": [],
"marketplace": [],
"organization_members": [
{

+ 3
- 10
server/sonar-web/src/main/js/app/components/App.tsx View File

@@ -20,11 +20,9 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent';
import { fetchMyOrganizations } from '../../apps/account/organizations/actions';
import { isSonarCloud } from '../../helpers/system';
import { isLoggedIn } from '../../helpers/users';
import { fetchLanguages } from '../../store/rootActions';
import { getAppState, getCurrentUser, getGlobalSettingValue, Store } from '../../store/rootReducer';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';

const PageTracker = lazyLoadComponent(() => import('./PageTracker'));

@@ -37,7 +35,6 @@ interface StateProps {

interface DispatchProps {
fetchLanguages: () => Promise<void>;
fetchMyOrganizations: () => Promise<void>;
}

interface OwnProps {
@@ -53,10 +50,6 @@ class App extends React.PureComponent<Props> {
this.mounted = true;
this.props.fetchLanguages();
this.setScrollbarWidth();
const { appState, currentUser } = this.props;
if (appState && isSonarCloud() && currentUser && isLoggedIn(currentUser)) {
this.props.fetchMyOrganizations();
}
}

componentWillUnmount() {
@@ -103,6 +96,7 @@ class App extends React.PureComponent<Props> {
<>
<PageTracker>{this.props.enableGravatar && this.renderPreconnectLink()}</PageTracker>
{this.props.children}
<KeyboardShortcutsModal />
</>
);
}
@@ -120,8 +114,7 @@ const mapStateToProps = (state: Store): StateProps => {
};

const mapDispatchToProps = ({
fetchLanguages,
fetchMyOrganizations
fetchLanguages
} as any) as DispatchProps;

export default connect(mapStateToProps, mapDispatchToProps)(App);

+ 152
- 0
server/sonar-web/src/main/js/app/components/KeyboardShortcutsModal.tsx View File

@@ -0,0 +1,152 @@
/*
* SonarQube
* Copyright (C) 2009-2020 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { Button } from 'sonar-ui-common/components/controls/buttons';
import Modal from 'sonar-ui-common/components/controls/Modal';
import { translate } from 'sonar-ui-common/helpers/l10n';

const CATEGORIES = [
{
category: 'global',
shortcuts: [
{ keys: ['s'], action: 'search' },
{ keys: ['?'], action: 'open_shortcuts' }
]
},
{
category: 'code_page',
shortcuts: [
{ keys: ['↑', '↓'], action: 'select_files' },
{ keys: ['→'], action: 'open_file' },
{ keys: ['←'], action: 'back' }
]
},
{
category: 'issues_page',
shortcuts: [
{ keys: ['↑', '↓'], action: 'navigate' },
{ keys: ['→'], action: 'source_code' },
{ keys: ['←'], action: 'back' },
{ keys: ['alt', '+', '↑', '↓'], action: 'navigate_locations' },
{ keys: ['alt', '+', '←', '→'], action: 'switch_flows' },
{ keys: ['f'], action: 'transition' },
{ keys: ['a'], action: 'assign' },
{ keys: ['m'], action: 'assign_to_me' },
{ keys: ['i'], action: 'severity' },
{ keys: ['c'], action: 'comment' },
{ keys: ['ctrl', '+', 'enter'], action: 'submit_comment' },
{ keys: ['t'], action: 'tags' }
]
},
{
category: 'measures_page',
shortcuts: [
{ keys: ['↑', '↓'], action: 'select_files' },
{ keys: ['→'], action: 'open_file' },
{ keys: ['←'], action: 'back' }
]
},
{
category: 'rules_page',
shortcuts: [
{ keys: ['↑', '↓'], action: 'navigate' },
{ keys: ['→'], action: 'rule_details' },
{ keys: ['←'], action: 'back' }
]
}
];

export default function KeyboardShortcutsModal() {
const [display, setDisplay] = React.useState(false);

React.useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
const { tagName } = event.target as HTMLElement;

if (['INPUT', 'SELECT', 'TEXTAREA'].includes(tagName)) {
return; // Ignore keys when typed in an input
}

if (event.key === '?') {
setDisplay(d => !d);
}
};

window.addEventListener('keypress', handleKeyPress);

return () => {
window.removeEventListener('keypress', handleKeyPress);
};
}, [setDisplay]);

if (!display) {
return null;
}

const title = translate('keyboard_shortcuts.title');

return (
<Modal contentLabel={title} onRequestClose={() => setDisplay(false)} size="medium">
<div className="modal-head">
<h2>{title}</h2>
</div>

<div className="modal-body modal-container markdown display-flex-wrap display-flex-space-between">
{CATEGORIES.map(({ category, shortcuts }) => (
<div key={category} className="spacer-right">
<h3>{translate('keyboard_shortcuts', category, 'title')}</h3>
<table>
<thead>
<tr>
<th>{translate('keyboard_shortcuts.shortcut')}</th>
<th>{translate('keyboard_shortcuts.action')}</th>
</tr>
</thead>
<tbody>
{shortcuts.map(({ action, keys }) => (
<tr key={action}>
<td>
{keys.map(k =>
k === '+' ? (
<span key={k} className="little-spacer-right">
{k}
</span>
) : (
<code key={k} className="little-spacer-right">
{k}
</code>
)
)}
</td>
<td>{translate('keyboard_shortcuts', category, action)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>

<div className="modal-foot">
<Button onClick={() => setDisplay(false)}>{translate('close')}</Button>
</div>
</Modal>
);
}

+ 79
- 0
server/sonar-web/src/main/js/app/components/__tests__/KeyboardShortcutsModal-test.tsx View File

@@ -0,0 +1,79 @@
/*
* SonarQube
* Copyright (C) 2009-2020 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* 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 * as React from 'react';
import Modal from 'sonar-ui-common/components/controls/Modal';
import { mockEvent } from '../../../helpers/testMocks';
import KeyboardShortcutsModal from '../KeyboardShortcutsModal';

let handle: void | (() => void);
beforeEach(() => {
jest.spyOn(React, 'useEffect').mockImplementationOnce(f => {
handle = f();
});
});

afterEach(() => {
if (handle) {
handle();
}
});

it('should render correctly', () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot('hidden');

window.dispatchEvent(new KeyboardEvent('keypress', { key: '?' }));

expect(wrapper).toMatchSnapshot('visible');
});

it('should close correctly', () => {
const wrapper = shallowRender();
window.dispatchEvent(new KeyboardEvent('keypress', { key: '?' }));

wrapper.find(Modal).props().onRequestClose!(mockEvent());

expect(wrapper.type()).toBeNull();
});

it('should ignore other keypresses', () => {
const wrapper = shallowRender();
window.dispatchEvent(new KeyboardEvent('keypress', { key: '!' }));
expect(wrapper.type()).toBeNull();
});

it.each([['input'], ['select'], ['textarea']])('should ignore events on a %s', type => {
const wrapper = shallowRender();

const fakeEvent = new KeyboardEvent('keypress', { key: '!' });

Object.defineProperty(fakeEvent, 'target', {
value: document.createElement(type)
});

window.dispatchEvent(fakeEvent);

expect(wrapper.type()).toBeNull();
});

function shallowRender() {
return shallow(<KeyboardShortcutsModal />);
}

+ 559
- 0
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/KeyboardShortcutsModal-test.tsx.snap View File

@@ -0,0 +1,559 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly: hidden 1`] = `""`;

exports[`should render correctly: visible 1`] = `
<Modal
contentLabel="keyboard_shortcuts.title"
onRequestClose={[Function]}
size="medium"
>
<div
className="modal-head"
>
<h2>
keyboard_shortcuts.title
</h2>
</div>
<div
className="modal-body modal-container markdown display-flex-wrap display-flex-space-between"
>
<div
className="spacer-right"
key="global"
>
<h3>
keyboard_shortcuts.global.title
</h3>
<table>
<thead>
<tr>
<th>
keyboard_shortcuts.shortcut
</th>
<th>
keyboard_shortcuts.action
</th>
</tr>
</thead>
<tbody>
<tr
key="search"
>
<td>
<code
className="little-spacer-right"
key="s"
>
s
</code>
</td>
<td>
keyboard_shortcuts.global.search
</td>
</tr>
<tr
key="open_shortcuts"
>
<td>
<code
className="little-spacer-right"
key="?"
>
?
</code>
</td>
<td>
keyboard_shortcuts.global.open_shortcuts
</td>
</tr>
</tbody>
</table>
</div>
<div
className="spacer-right"
key="code_page"
>
<h3>
keyboard_shortcuts.code_page.title
</h3>
<table>
<thead>
<tr>
<th>
keyboard_shortcuts.shortcut
</th>
<th>
keyboard_shortcuts.action
</th>
</tr>
</thead>
<tbody>
<tr
key="select_files"
>
<td>
<code
className="little-spacer-right"
key="↑"
>
</code>
<code
className="little-spacer-right"
key="↓"
>
</code>
</td>
<td>
keyboard_shortcuts.code_page.select_files
</td>
</tr>
<tr
key="open_file"
>
<td>
<code
className="little-spacer-right"
key="→"
>
</code>
</td>
<td>
keyboard_shortcuts.code_page.open_file
</td>
</tr>
<tr
key="back"
>
<td>
<code
className="little-spacer-right"
key="←"
>
</code>
</td>
<td>
keyboard_shortcuts.code_page.back
</td>
</tr>
</tbody>
</table>
</div>
<div
className="spacer-right"
key="issues_page"
>
<h3>
keyboard_shortcuts.issues_page.title
</h3>
<table>
<thead>
<tr>
<th>
keyboard_shortcuts.shortcut
</th>
<th>
keyboard_shortcuts.action
</th>
</tr>
</thead>
<tbody>
<tr
key="navigate"
>
<td>
<code
className="little-spacer-right"
key="↑"
>
</code>
<code
className="little-spacer-right"
key="↓"
>
</code>
</td>
<td>
keyboard_shortcuts.issues_page.navigate
</td>
</tr>
<tr
key="source_code"
>
<td>
<code
className="little-spacer-right"
key="→"
>
</code>
</td>
<td>
keyboard_shortcuts.issues_page.source_code
</td>
</tr>
<tr
key="back"
>
<td>
<code
className="little-spacer-right"
key="←"
>
</code>
</td>
<td>
keyboard_shortcuts.issues_page.back
</td>
</tr>
<tr
key="navigate_locations"
>
<td>
<code
className="little-spacer-right"
key="alt"
>
alt
</code>
<span
className="little-spacer-right"
key="+"
>
+
</span>
<code
className="little-spacer-right"
key="↑"
>
</code>
<code
className="little-spacer-right"
key="↓"
>
</code>
</td>
<td>
keyboard_shortcuts.issues_page.navigate_locations
</td>
</tr>
<tr
key="switch_flows"
>
<td>
<code
className="little-spacer-right"
key="alt"
>
alt
</code>
<span
className="little-spacer-right"
key="+"
>
+
</span>
<code
className="little-spacer-right"
key="←"
>
</code>
<code
className="little-spacer-right"
key="→"
>
</code>
</td>
<td>
keyboard_shortcuts.issues_page.switch_flows
</td>
</tr>
<tr
key="transition"
>
<td>
<code
className="little-spacer-right"
key="f"
>
f
</code>
</td>
<td>
keyboard_shortcuts.issues_page.transition
</td>
</tr>
<tr
key="assign"
>
<td>
<code
className="little-spacer-right"
key="a"
>
a
</code>
</td>
<td>
keyboard_shortcuts.issues_page.assign
</td>
</tr>
<tr
key="assign_to_me"
>
<td>
<code
className="little-spacer-right"
key="m"
>
m
</code>
</td>
<td>
keyboard_shortcuts.issues_page.assign_to_me
</td>
</tr>
<tr
key="severity"
>
<td>
<code
className="little-spacer-right"
key="i"
>
i
</code>
</td>
<td>
keyboard_shortcuts.issues_page.severity
</td>
</tr>
<tr
key="comment"
>
<td>
<code
className="little-spacer-right"
key="c"
>
c
</code>
</td>
<td>
keyboard_shortcuts.issues_page.comment
</td>
</tr>
<tr
key="submit_comment"
>
<td>
<code
className="little-spacer-right"
key="ctrl"
>
ctrl
</code>
<span
className="little-spacer-right"
key="+"
>
+
</span>
<code
className="little-spacer-right"
key="enter"
>
enter
</code>
</td>
<td>
keyboard_shortcuts.issues_page.submit_comment
</td>
</tr>
<tr
key="tags"
>
<td>
<code
className="little-spacer-right"
key="t"
>
t
</code>
</td>
<td>
keyboard_shortcuts.issues_page.tags
</td>
</tr>
</tbody>
</table>
</div>
<div
className="spacer-right"
key="measures_page"
>
<h3>
keyboard_shortcuts.measures_page.title
</h3>
<table>
<thead>
<tr>
<th>
keyboard_shortcuts.shortcut
</th>
<th>
keyboard_shortcuts.action
</th>
</tr>
</thead>
<tbody>
<tr
key="select_files"
>
<td>
<code
className="little-spacer-right"
key="↑"
>
</code>
<code
className="little-spacer-right"
key="↓"
>
</code>
</td>
<td>
keyboard_shortcuts.measures_page.select_files
</td>
</tr>
<tr
key="open_file"
>
<td>
<code
className="little-spacer-right"
key="→"
>
</code>
</td>
<td>
keyboard_shortcuts.measures_page.open_file
</td>
</tr>
<tr
key="back"
>
<td>
<code
className="little-spacer-right"
key="←"
>
</code>
</td>
<td>
keyboard_shortcuts.measures_page.back
</td>
</tr>
</tbody>
</table>
</div>
<div
className="spacer-right"
key="rules_page"
>
<h3>
keyboard_shortcuts.rules_page.title
</h3>
<table>
<thead>
<tr>
<th>
keyboard_shortcuts.shortcut
</th>
<th>
keyboard_shortcuts.action
</th>
</tr>
</thead>
<tbody>
<tr
key="navigate"
>
<td>
<code
className="little-spacer-right"
key="↑"
>
</code>
<code
className="little-spacer-right"
key="↓"
>
</code>
</td>
<td>
keyboard_shortcuts.rules_page.navigate
</td>
</tr>
<tr
key="rule_details"
>
<td>
<code
className="little-spacer-right"
key="→"
>
</code>
</td>
<td>
keyboard_shortcuts.rules_page.rule_details
</td>
</tr>
<tr
key="back"
>
<td>
<code
className="little-spacer-right"
key="←"
>
</code>
</td>
<td>
keyboard_shortcuts.rules_page.back
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
className="modal-foot"
>
<Button
onClick={[Function]}
>
close
</Button>
</div>
</Modal>
`;

+ 0
- 18
server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx View File

@@ -34,24 +34,6 @@ export default class EmbedDocsPopupHelper extends React.PureComponent<{}, State>
mounted = false;
state: State = { helpOpen: false };

componentDidMount() {
window.addEventListener('keypress', this.onKeyPress);
}

componentWillUnmount() {
window.removeEventListener('keypress', this.onKeyPress);
}

onKeyPress = (event: KeyboardEvent) => {
const { tagName } = event.target as HTMLElement;
const code = event.keyCode || event.which;
const isInput = tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA';
const isTriggerKey = code === 63;
if (!isInput && isTriggerKey) {
this.toggleHelp();
}
};

setHelpDisplay = (helpOpen: boolean) => {
this.setState({ helpOpen });
};

+ 39
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -2473,6 +2473,45 @@ permission_templates.delete_selected=Delete all selected items
#------------------------------------------------------------------------------
markdown.helplink=Markdown Help

#------------------------------------------------------------------------------
#
# KEYBOARD SHORTCUTS
#
#------------------------------------------------------------------------------

keyboard_shortcuts.title=Keyboard Shortcuts
keyboard_shortcuts.shortcut=Shortcut
keyboard_shortcuts.action=Action
keyboard_shortcuts.global.title=Global
keyboard_shortcuts.global.search=Open the search bar
keyboard_shortcuts.global.open_shortcuts=Open this panel
keyboard_shortcuts.code_page.title=Code Page
keyboard_shortcuts.code_page.select_files=Select files
keyboard_shortcuts.code_page.open_file=Open the selected file
keyboard_shortcuts.code_page.back=Return back to the list
keyboard_shortcuts.issues_page.title=Issues Page
keyboard_shortcuts.issues_page.navigate=navigate between issues
keyboard_shortcuts.issues_page.source_code=go from the list of issues to the source code
keyboard_shortcuts.issues_page.back=return back to the list
keyboard_shortcuts.issues_page.navigate_locations=to navigate issue locations
keyboard_shortcuts.issues_page.switch_flows=to switch flows
keyboard_shortcuts.issues_page.transition=do an issue transition
keyboard_shortcuts.issues_page.assign=assign issue
keyboard_shortcuts.issues_page.assign_to_me=assign issue to the current user
keyboard_shortcuts.issues_page.severity=change severity of issue
keyboard_shortcuts.issues_page.comment=comment issue
keyboard_shortcuts.issues_page.submit_comment=submit comment
keyboard_shortcuts.issues_page.tags=change tags of issue
keyboard_shortcuts.measures_page.title=Measures Page
keyboard_shortcuts.measures_page.select_files=Select files
keyboard_shortcuts.measures_page.open_file=Open the selected file
keyboard_shortcuts.measures_page.back=Return back to the list
keyboard_shortcuts.rules_page.title=Rules Page
keyboard_shortcuts.rules_page.navigate=navigate between rules
keyboard_shortcuts.rules_page.rule_details=go from the list of rules to the rule details
keyboard_shortcuts.rules_page.back=Return back to the list


#------------------------------------------------------------------------------
#
# DURATION

Loading…
Cancel
Save