aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/components
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2019-01-17 08:50:30 +0100
committersonartech <sonartech@sonarsource.com>2019-02-11 09:11:24 +0100
commitb6aeddaea44525337d14ed3566fcd5f08d1e671f (patch)
treec282b7f8b88b6c945e507465aa321e52fb4c37b9 /server/sonar-web/src/main/js/components
parent8b7cd93d7a751fd49e8df3faef1cf03e320f470a (diff)
downloadsonarqube-b6aeddaea44525337d14ed3566fcd5f08d1e671f.tar.gz
sonarqube-b6aeddaea44525337d14ed3566fcd5f08d1e671f.zip
SONAR-8697 Enable keyboard file navigation in Code page
Diffstat (limited to 'server/sonar-web/src/main/js/components')
-rw-r--r--server/sonar-web/src/main/js/components/docs/DocLink.tsx2
-rw-r--r--server/sonar-web/src/main/js/components/hoc/__tests__/utils-test.ts35
-rw-r--r--server/sonar-web/src/main/js/components/hoc/__tests__/withKeyboardNavigation-test.tsx178
-rw-r--r--server/sonar-web/src/main/js/components/hoc/utils.ts23
-rw-r--r--server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withAppState.tsx (renamed from server/sonar-web/src/main/js/components/withAppState.tsx)7
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withKeyboardNavigation.tsx193
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withScrollTo.tsx83
-rw-r--r--server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx5
-rw-r--r--server/sonar-web/src/main/js/components/ui/FilesCounter.tsx45
-rw-r--r--server/sonar-web/src/main/js/components/ui/PageActions.tsx84
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx30
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx74
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap25
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap133
-rw-r--r--server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx2
17 files changed, 914 insertions, 15 deletions
diff --git a/server/sonar-web/src/main/js/components/docs/DocLink.tsx b/server/sonar-web/src/main/js/components/docs/DocLink.tsx
index 8e8ac6f5680..e2f7198e8a9 100644
--- a/server/sonar-web/src/main/js/components/docs/DocLink.tsx
+++ b/server/sonar-web/src/main/js/components/docs/DocLink.tsx
@@ -21,7 +21,7 @@ import * as React from 'react';
import { Link } from 'react-router';
import DetachIcon from '../icons-components/DetachIcon';
import { isSonarCloud } from '../../helpers/system';
-import { withAppState } from '../withAppState';
+import { withAppState } from '../hoc/withAppState';
interface OwnProps {
appState: Pick<T.AppState, 'canAdmin'>;
diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/utils-test.ts b/server/sonar-web/src/main/js/components/hoc/__tests__/utils-test.ts
new file mode 100644
index 00000000000..78f6cb0575b
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/__tests__/utils-test.ts
@@ -0,0 +1,35 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { getWrappedDisplayName } from '../utils';
+
+it('should compute the name correctly', () => {
+ expect(getWrappedDisplayName({} as any, 'myName')).toBe('myName(Component)');
+
+ class DummyWrapper extends React.Component {}
+
+ expect(getWrappedDisplayName(DummyWrapper, 'myName')).toBe('myName(DummyWrapper)');
+
+ class DummyWrapper2 extends React.Component {
+ static displayName = 'Foo';
+ }
+
+ expect(getWrappedDisplayName(DummyWrapper2, 'myName')).toBe('myName(Foo)');
+});
diff --git a/server/sonar-web/src/main/js/components/hoc/__tests__/withKeyboardNavigation-test.tsx b/server/sonar-web/src/main/js/components/hoc/__tests__/withKeyboardNavigation-test.tsx
new file mode 100644
index 00000000000..1231f26c211
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/__tests__/withKeyboardNavigation-test.tsx
@@ -0,0 +1,178 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { mount, shallow } from 'enzyme';
+import withKeyboardNavigation, { WithKeyboardNavigationProps } from '../withKeyboardNavigation';
+import { mockComponent, keydown, KEYCODE_MAP } from '../../../helpers/testUtils';
+
+class X extends React.Component<{
+ components?: T.ComponentMeasure[];
+ selected?: T.ComponentMeasure;
+}> {
+ render() {
+ return <div />;
+ }
+}
+
+const WrappedComponent = withKeyboardNavigation(X);
+
+const COMPONENTS = [
+ mockComponent({ key: 'file-1' }),
+ mockComponent({ key: 'file-2' }),
+ mockComponent({ key: 'file-3' })
+];
+
+jest.mock('keymaster', () => {
+ const key: any = (bindKey: string, _: string, callback: Function) => {
+ document.addEventListener('keydown', (event: KeyboardEvent) => {
+ if (bindKey.split(',').includes(KEYCODE_MAP[event.keyCode])) {
+ return callback();
+ }
+ return true;
+ });
+ };
+
+ key.setScope = jest.fn();
+ key.deleteScope = jest.fn();
+
+ return key;
+});
+
+it('should wrap component correctly', () => {
+ const wrapper = shallow(applyProps());
+ expect(wrapper.find('X').exists()).toBe(true);
+});
+
+it('should correctly bind key events for component navigation', () => {
+ const onGoToParent = jest.fn();
+ const onHighlight = jest.fn(selected => {
+ wrapper.setProps({ selected });
+ });
+ const onSelect = jest.fn();
+
+ const wrapper = mount(
+ applyProps({
+ cycle: true,
+ onGoToParent,
+ onHighlight,
+ onSelect,
+ selected: COMPONENTS[1]
+ })
+ );
+
+ keydown('down');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[2]);
+ expect(onSelect).not.toBeCalled();
+
+ keydown('up');
+ keydown('up');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[0]);
+ expect(onSelect).not.toBeCalled();
+
+ keydown('up');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[2]);
+
+ keydown('down');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[0]);
+
+ keydown('right');
+ expect(onSelect).toBeCalledWith(COMPONENTS[0]);
+
+ keydown('enter');
+ expect(onSelect).toBeCalledWith(COMPONENTS[0]);
+
+ keydown('left');
+ expect(onGoToParent).toBeCalled();
+});
+
+it('should support not cycling through elements, and triggering a callback on reaching the last element', () => {
+ const onEndOfList = jest.fn();
+ const onHighlight = jest.fn(selected => {
+ wrapper.setProps({ selected });
+ });
+
+ const wrapper = mount(
+ applyProps({
+ onEndOfList,
+ onHighlight
+ })
+ );
+
+ keydown('down');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[0]);
+ keydown('down');
+ keydown('down');
+ keydown('down');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[2]);
+ expect(onEndOfList).toBeCalled();
+
+ keydown('up');
+ keydown('up');
+ keydown('up');
+ keydown('up');
+ expect(onHighlight).toBeCalledWith(COMPONENTS[0]);
+});
+
+it('should correctly bind key events for sibling navigation', () => {
+ const onGoToParent = jest.fn();
+ const onHighlight = jest.fn();
+ const onSelect = jest.fn();
+
+ mount(
+ applyProps({
+ isFile: true,
+ onGoToParent,
+ onHighlight,
+ onSelect,
+ selected: COMPONENTS[1]
+ })
+ );
+
+ expect(onHighlight).not.toBeCalled();
+
+ keydown('down');
+ expect(onHighlight).not.toBeCalled();
+ expect(onSelect).not.toBeCalled();
+
+ keydown('up');
+ expect(onHighlight).not.toBeCalled();
+ expect(onSelect).not.toBeCalled();
+
+ keydown('right');
+ expect(onHighlight).not.toBeCalled();
+ expect(onSelect).not.toBeCalled();
+
+ keydown('enter');
+ expect(onHighlight).not.toBeCalled();
+ expect(onSelect).not.toBeCalled();
+
+ keydown('j');
+ expect(onSelect).toBeCalledWith(COMPONENTS[2]);
+
+ keydown('k');
+ expect(onSelect).toBeCalledWith(COMPONENTS[0]);
+
+ keydown('left');
+ expect(onGoToParent).toBeCalled();
+});
+
+function applyProps(props: Partial<WithKeyboardNavigationProps> = {}) {
+ return <WrappedComponent components={COMPONENTS} {...props} />;
+}
diff --git a/server/sonar-web/src/main/js/components/hoc/utils.ts b/server/sonar-web/src/main/js/components/hoc/utils.ts
new file mode 100644
index 00000000000..e324bcf7757
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/utils.ts
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+export function getWrappedDisplayName(WrappedComponent: React.ComponentClass, hocName: string) {
+ const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
+ return `${hocName}(${wrappedDisplayName})`;
+}
diff --git a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx
index 6b223deeaf5..2fd85afbfbb 100644
--- a/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx
+++ b/server/sonar-web/src/main/js/components/hoc/whenLoggedIn.tsx
@@ -18,15 +18,14 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import { getWrappedDisplayName } from './utils';
import { withCurrentUser } from './withCurrentUser';
import { isLoggedIn } from '../../helpers/users';
import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthentication';
export function whenLoggedIn<P>(WrappedComponent: React.ComponentClass<P>) {
- const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
-
class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> {
- static displayName = `whenLoggedIn(${wrappedDisplayName})`;
+ static displayName = getWrappedDisplayName(WrappedComponent, 'whenLoggedIn');
componentDidMount() {
if (!isLoggedIn(this.props.currentUser)) {
diff --git a/server/sonar-web/src/main/js/components/withAppState.tsx b/server/sonar-web/src/main/js/components/hoc/withAppState.tsx
index 9a52eac8f62..0e6e3f251cf 100644
--- a/server/sonar-web/src/main/js/components/withAppState.tsx
+++ b/server/sonar-web/src/main/js/components/hoc/withAppState.tsx
@@ -19,15 +19,14 @@
*/
import * as React from 'react';
import { connect } from 'react-redux';
-import { Store, getAppState } from '../store/rootReducer';
+import { getWrappedDisplayName } from './utils';
+import { Store, getAppState } from '../../store/rootReducer';
export function withAppState<P>(
WrappedComponent: React.ComponentClass<P & { appState: Partial<T.AppState> }>
) {
- const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
-
class Wrapper extends React.Component<P & { appState: T.AppState }> {
- static displayName = `withAppState(${wrappedDisplayName})`;
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withAppState');
render() {
return <WrappedComponent {...this.props} />;
diff --git a/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx
index e5f8ca3fe64..8a11dbe7428 100644
--- a/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx
+++ b/server/sonar-web/src/main/js/components/hoc/withCurrentUser.tsx
@@ -19,15 +19,14 @@
*/
import * as React from 'react';
import { connect } from 'react-redux';
+import { getWrappedDisplayName } from './utils';
import { Store, getCurrentUser } from '../../store/rootReducer';
export function withCurrentUser<P>(
WrappedComponent: React.ComponentClass<P & { currentUser: T.CurrentUser }>
) {
- const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
-
class Wrapper extends React.Component<P & { currentUser: T.CurrentUser }> {
- static displayName = `withCurrentUser(${wrappedDisplayName})`;
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withCurrentUser');
render() {
return <WrappedComponent {...this.props} />;
diff --git a/server/sonar-web/src/main/js/components/hoc/withKeyboardNavigation.tsx b/server/sonar-web/src/main/js/components/hoc/withKeyboardNavigation.tsx
new file mode 100644
index 00000000000..38f7cac5ebd
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/withKeyboardNavigation.tsx
@@ -0,0 +1,193 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 * as key from 'keymaster';
+import { getWrappedDisplayName } from './utils';
+import PageActions from '../ui/PageActions';
+
+export interface WithKeyboardNavigationProps {
+ components?: T.ComponentMeasure[];
+ cycle?: boolean;
+ isFile?: boolean;
+ onEndOfList?: () => void;
+ onGoToParent?: () => void;
+ onHighlight?: (item: T.ComponentMeasure) => void;
+ onSelect?: (item: T.ComponentMeasure) => void;
+ selected?: T.ComponentMeasure;
+}
+
+const KEY_SCOPE = 'key_nav';
+
+export default function withKeyboardNavigation<P>(
+ WrappedComponent: React.ComponentClass<P & Partial<WithKeyboardNavigationProps>>
+) {
+ return class Wrapper extends React.Component<P & WithKeyboardNavigationProps> {
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withKeyboardNavigation');
+
+ componentDidMount() {
+ this.attachShortcuts();
+ }
+
+ componentWillUnmount() {
+ this.detachShortcuts();
+ }
+
+ attachShortcuts = () => {
+ key.setScope(KEY_SCOPE);
+ key('up', KEY_SCOPE, () => {
+ return this.skipIfFile(this.handleHighlightPrevious);
+ });
+ key('down', KEY_SCOPE, () => {
+ return this.skipIfFile(this.handleHighlightNext);
+ });
+ key('right,enter', KEY_SCOPE, () => {
+ return this.skipIfFile(this.handleSelectCurrent);
+ });
+ key('left', KEY_SCOPE, () => {
+ this.handleSelectParent();
+ return false; // always hijack left
+ });
+ key('k', KEY_SCOPE, () => {
+ return this.skipIfNotFile(this.handleSelectPrevious);
+ });
+ key('j', KEY_SCOPE, () => {
+ return this.skipIfNotFile(this.handleSelectNext);
+ });
+ };
+
+ detachShortcuts = () => {
+ key.deleteScope(KEY_SCOPE);
+ };
+
+ getCurrentIndex = () => {
+ const { selected, components = [] } = this.props;
+ return selected ? components.findIndex(component => component.key === selected.key) : -1;
+ };
+
+ skipIfFile = (handler: () => void) => {
+ if (this.props.isFile) {
+ return true;
+ } else {
+ handler();
+ return false;
+ }
+ };
+
+ skipIfNotFile = (handler: () => void) => {
+ if (this.props.isFile) {
+ handler();
+ return false;
+ } else {
+ return true;
+ }
+ };
+
+ handleHighlightNext = () => {
+ if (this.props.onHighlight === undefined) {
+ return;
+ }
+
+ const { components = [], cycle } = this.props;
+ const index = this.getCurrentIndex();
+ const first = cycle ? 0 : index;
+
+ this.props.onHighlight(
+ index < components.length - 1 ? components[index + 1] : components[first]
+ );
+
+ if (index + 1 === components.length - 1 && this.props.onEndOfList) {
+ this.props.onEndOfList();
+ }
+ };
+
+ handleHighlightPrevious = () => {
+ if (this.props.onHighlight === undefined) {
+ return;
+ }
+ const { components = [], cycle } = this.props;
+ const index = this.getCurrentIndex();
+ const last = cycle ? components.length - 1 : index;
+
+ this.props.onHighlight(index > 0 ? components[index - 1] : components[last]);
+ };
+
+ handleSelectCurrent = () => {
+ if (this.props.onSelect === undefined) {
+ return;
+ }
+
+ const { selected } = this.props;
+ if (selected !== undefined) {
+ this.props.onSelect(selected as T.ComponentMeasure);
+ }
+ };
+
+ handleSelectNext = () => {
+ if (this.props.onSelect === undefined) {
+ return;
+ }
+
+ const { components = [] } = this.props;
+ const index = this.getCurrentIndex();
+
+ if (index !== -1 && index < components.length - 1) {
+ this.props.onSelect(components[index + 1]);
+ }
+ };
+
+ handleSelectParent = () => {
+ if (this.props.onGoToParent !== undefined) {
+ this.props.onGoToParent();
+ }
+ };
+
+ handleSelectPrevious = () => {
+ if (this.props.onSelect === undefined) {
+ return;
+ }
+
+ const { components = [] } = this.props;
+ const index = this.getCurrentIndex();
+
+ if (components.length && index > 0) {
+ this.props.onSelect(components[index - 1]);
+ }
+ };
+
+ render() {
+ const { components = [], isFile } = this.props;
+ const index = this.getCurrentIndex();
+
+ return (
+ <>
+ <PageActions
+ current={index > -1 ? index + 1 : undefined}
+ isFile={isFile}
+ showPaging={isFile && index > -1}
+ showShortcuts={true}
+ totalLoadedComponents={components.length}
+ />
+
+ <WrappedComponent {...this.props} />
+ </>
+ );
+ }
+ };
+}
diff --git a/server/sonar-web/src/main/js/components/hoc/withScrollTo.tsx b/server/sonar-web/src/main/js/components/hoc/withScrollTo.tsx
new file mode 100644
index 00000000000..86efa6d66fa
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/hoc/withScrollTo.tsx
@@ -0,0 +1,83 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { findDOMNode } from 'react-dom';
+import { getWrappedDisplayName } from './utils';
+
+export interface WithScrollToProps {
+ selected?: boolean;
+}
+
+const TOP_OFFSET = 200;
+const BOTTOM_OFFSET = 10;
+
+export function withScrollTo<P>(WrappedComponent: React.ComponentClass<P>) {
+ return class Wrapper extends React.Component<P & Partial<WithScrollToProps>> {
+ componentRef?: React.Component | null;
+ node?: Element | Text | null;
+
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withScrollTo');
+
+ componentDidMount() {
+ if (this.componentRef) {
+ // eslint-disable-next-line react/no-find-dom-node
+ this.node = findDOMNode(this.componentRef);
+ this.handleUpdate();
+ }
+ }
+
+ componentDidUpdate() {
+ this.handleUpdate();
+ }
+
+ handleUpdate() {
+ const { selected } = this.props;
+
+ if (selected) {
+ setTimeout(() => {
+ this.handleScroll();
+ }, 0);
+ }
+ }
+
+ handleScroll() {
+ if (this.node && this.node instanceof Element) {
+ const position = this.node.getBoundingClientRect();
+ const { top, bottom } = position;
+ if (bottom > window.innerHeight - BOTTOM_OFFSET) {
+ window.scrollTo(0, bottom - window.innerHeight + window.pageYOffset + BOTTOM_OFFSET);
+ } else if (top < TOP_OFFSET) {
+ window.scrollTo(0, top + window.pageYOffset - TOP_OFFSET);
+ }
+ }
+ }
+
+ render() {
+ return (
+ <WrappedComponent
+ {...this.props}
+ ref={ref => {
+ this.componentRef = ref;
+ }}
+ />
+ );
+ }
+ };
+}
diff --git a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx
index ecdbad5fe90..991005055e2 100644
--- a/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx
+++ b/server/sonar-web/src/main/js/components/hoc/withUserOrganizations.tsx
@@ -19,6 +19,7 @@
*/
import * as React from 'react';
import { connect } from 'react-redux';
+import { getWrappedDisplayName } from './utils';
import { Store, getMyOrganizations } from '../../store/rootReducer';
import { fetchMyOrganizations } from '../../apps/account/organizations/actions';
@@ -30,10 +31,8 @@ interface OwnProps {
export function withUserOrganizations<P>(
WrappedComponent: React.ComponentClass<P & Partial<OwnProps>>
) {
- const wrappedDisplayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
-
class Wrapper extends React.Component<P & OwnProps> {
- static displayName = `withUserOrganizations(${wrappedDisplayName})`;
+ static displayName = getWrappedDisplayName(WrappedComponent, 'withUserOrganizations');
componentDidMount() {
this.props.fetchMyOrganizations();
diff --git a/server/sonar-web/src/main/js/components/ui/FilesCounter.tsx b/server/sonar-web/src/main/js/components/ui/FilesCounter.tsx
new file mode 100644
index 00000000000..09ef95d5005
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/FilesCounter.tsx
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { translate } from '../../helpers/l10n';
+import { formatMeasure } from '../../helpers/measures';
+
+interface Props {
+ className?: string;
+ current?: number;
+ total: number;
+}
+
+export default function FilesCounter({ className, current, total }: Props) {
+ return (
+ <span className={className}>
+ <strong>
+ {current !== undefined && (
+ <span>
+ {formatMeasure(current, 'INT')}
+ {' / '}
+ </span>
+ )}
+ {formatMeasure(total, 'INT')}
+ </strong>{' '}
+ {translate('component_measures.files')}
+ </span>
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/ui/PageActions.tsx b/server/sonar-web/src/main/js/components/ui/PageActions.tsx
new file mode 100644
index 00000000000..4e6d6386c2a
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/PageActions.tsx
@@ -0,0 +1,84 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 FilesCounter from './FilesCounter';
+import { translate } from '../../helpers/l10n';
+
+interface Props {
+ current?: number;
+ isFile?: boolean;
+ paging?: T.Paging;
+ showPaging?: boolean;
+ showShortcuts?: boolean;
+ totalLoadedComponents?: number;
+}
+
+export default function PageActions(props: Props) {
+ const { isFile, paging, showPaging, showShortcuts, totalLoadedComponents } = props;
+ let total = 0;
+
+ if (showPaging && totalLoadedComponents) {
+ total = totalLoadedComponents;
+ } else if (paging !== undefined) {
+ total = isFile && totalLoadedComponents ? totalLoadedComponents : paging.total;
+ }
+
+ return (
+ <div className="page-actions display-flex-center">
+ {!isFile && showShortcuts && renderShortcuts()}
+ {isFile && (paging || showPaging) && renderFileShortcuts()}
+ {total > 0 && (
+ <div className="measure-details-page-actions nowrap">
+ <FilesCounter className="big-spacer-left" current={props.current} total={total} />
+ </div>
+ )}
+ </div>
+ );
+}
+
+function renderShortcuts() {
+ return (
+ <span className="note nowrap">
+ <span className="big-spacer-right">
+ <span className="shortcut-button little-spacer-right">↑</span>
+ <span className="shortcut-button little-spacer-right">↓</span>
+ {translate('component_measures.to_select_files')}
+ </span>
+
+ <span>
+ <span className="shortcut-button little-spacer-right">←</span>
+ <span className="shortcut-button little-spacer-right">→</span>
+ {translate('component_measures.to_navigate')}
+ </span>
+ </span>
+ );
+}
+
+function renderFileShortcuts() {
+ return (
+ <span className="note nowrap">
+ <span>
+ <span className="shortcut-button little-spacer-right">j</span>
+ <span className="shortcut-button little-spacer-right">k</span>
+ {translate('component_measures.to_navigate_files')}
+ </span>
+ </span>
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx
new file mode 100644
index 00000000000..374631e3762
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { shallow } from 'enzyme';
+import FilesCounter from '../FilesCounter';
+
+it('should display x files on y total', () => {
+ expect(shallow(<FilesCounter current={12} total={123455} />)).toMatchSnapshot();
+});
+
+it('should display only total of files', () => {
+ expect(shallow(<FilesCounter current={undefined} total={123455} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx
new file mode 100644
index 00000000000..edefa9311ee
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { shallow } from 'enzyme';
+import PageActions from '../PageActions';
+
+const PAGING = {
+ pageIndex: 1,
+ pageSize: 100,
+ total: 120
+};
+
+it('should display correctly for a project', () => {
+ expect(
+ shallow(<PageActions isFile={false} showShortcuts={true} totalLoadedComponents={20} />)
+ ).toMatchSnapshot();
+});
+
+it('should display correctly for a file', () => {
+ const wrapper = shallow(
+ <PageActions isFile={true} showShortcuts={true} totalLoadedComponents={10} />
+ );
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setProps({ paging: { total: 100 } });
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should not display shortcuts for treemap', () => {
+ expect(
+ shallow(<PageActions isFile={false} showShortcuts={false} totalLoadedComponents={20} />)
+ ).toMatchSnapshot();
+});
+
+it('should display the total of files', () => {
+ expect(
+ shallow(
+ <PageActions
+ current={12}
+ isFile={false}
+ paging={PAGING}
+ showShortcuts={false}
+ totalLoadedComponents={20}
+ />
+ )
+ ).toMatchSnapshot();
+ expect(
+ shallow(
+ <PageActions
+ current={12}
+ isFile={true}
+ paging={PAGING}
+ showShortcuts={true}
+ totalLoadedComponents={20}
+ />
+ )
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap
new file mode 100644
index 00000000000..bb01a6121da
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display only total of files 1`] = `
+<span>
+ <strong>
+ 123,455
+ </strong>
+
+ component_measures.files
+</span>
+`;
+
+exports[`should display x files on y total 1`] = `
+<span>
+ <strong>
+ <span>
+ 12
+ /
+ </span>
+ 123,455
+ </strong>
+
+ component_measures.files
+</span>
+`;
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap
new file mode 100644
index 00000000000..d76eabe318c
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap
@@ -0,0 +1,133 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display correctly for a file 1`] = `
+<div
+ className="page-actions display-flex-center"
+/>
+`;
+
+exports[`should display correctly for a file 2`] = `
+<div
+ className="page-actions display-flex-center"
+>
+ <span
+ className="note nowrap"
+ >
+ <span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ j
+ </span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ k
+ </span>
+ component_measures.to_navigate_files
+ </span>
+ </span>
+ <div
+ className="measure-details-page-actions nowrap"
+ >
+ <FilesCounter
+ className="big-spacer-left"
+ total={10}
+ />
+ </div>
+</div>
+`;
+
+exports[`should display correctly for a project 1`] = `
+<div
+ className="page-actions display-flex-center"
+>
+ <span
+ className="note nowrap"
+ >
+ <span
+ className="big-spacer-right"
+ >
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ ↑
+ </span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ ↓
+ </span>
+ component_measures.to_select_files
+ </span>
+ <span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ ←
+ </span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ →
+ </span>
+ component_measures.to_navigate
+ </span>
+ </span>
+</div>
+`;
+
+exports[`should display the total of files 1`] = `
+<div
+ className="page-actions display-flex-center"
+>
+ <div
+ className="measure-details-page-actions nowrap"
+ >
+ <FilesCounter
+ className="big-spacer-left"
+ current={12}
+ total={120}
+ />
+ </div>
+</div>
+`;
+
+exports[`should display the total of files 2`] = `
+<div
+ className="page-actions display-flex-center"
+>
+ <span
+ className="note nowrap"
+ >
+ <span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ j
+ </span>
+ <span
+ className="shortcut-button little-spacer-right"
+ >
+ k
+ </span>
+ component_measures.to_navigate_files
+ </span>
+ </span>
+ <div
+ className="measure-details-page-actions nowrap"
+ >
+ <FilesCounter
+ className="big-spacer-left"
+ current={12}
+ total={20}
+ />
+ </div>
+</div>
+`;
+
+exports[`should not display shortcuts for treemap 1`] = `
+<div
+ className="page-actions display-flex-center"
+/>
+`;
diff --git a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx
index 03483a621b0..6560908b09a 100644
--- a/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx
+++ b/server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx
@@ -19,7 +19,7 @@
*/
import * as React from 'react';
import { keyBy } from 'lodash';
-import { withAppState } from '../withAppState';
+import { withAppState } from '../hoc/withAppState';
import DeferredSpinner from '../common/DeferredSpinner';
import RuleDetailsMeta from '../../apps/coding-rules/components/RuleDetailsMeta';
import RuleDetailsDescription from '../../apps/coding-rules/components/RuleDetailsDescription';