diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2019-01-17 08:50:30 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-02-11 09:11:24 +0100 |
commit | b6aeddaea44525337d14ed3566fcd5f08d1e671f (patch) | |
tree | c282b7f8b88b6c945e507465aa321e52fb4c37b9 /server/sonar-web/src/main/js/components | |
parent | 8b7cd93d7a751fd49e8df3faef1cf03e320f470a (diff) | |
download | sonarqube-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')
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'; |