aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js
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
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')
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavLicenseNotif.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/code/code.css16
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/App.tsx195
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Component.tsx43
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx29
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/ComponentName.tsx116
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Components.tsx105
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/Search.tsx81
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx34
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/__tests__/App-test.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/__tests__/Components-test.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Components-test.tsx.snap13
-rw-r--r--server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsIssues.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/style.css4
-rw-r--r--server/sonar-web/src/main/js/apps/organizations/forSingleOrganization.tsx6
-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.tsx (renamed from server/sonar-web/src/main/js/apps/component-measures/components/FilesCounter.tsx)4
-rw-r--r--server/sonar-web/src/main/js/components/ui/PageActions.tsx (renamed from server/sonar-web/src/main/js/apps/component-measures/components/PageActions.tsx)39
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx (renamed from server/sonar-web/src/main/js/apps/component-measures/components/__tests__/FilesCounter-test.tsx)0
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx (renamed from server/sonar-web/src/main/js/apps/component-measures/components/__tests__/PageActions-test.tsx)12
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap (renamed from server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/FilesCounter-test.tsx.snap)0
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap (renamed from server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/PageActions-test.tsx.snap)39
-rw-r--r--server/sonar-web/src/main/js/components/workspace/WorkspaceRuleDetails.tsx2
-rw-r--r--server/sonar-web/src/main/js/helpers/testUtils.ts25
38 files changed, 933 insertions, 387 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
index cff73c5e70f..aabed93c940 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx
@@ -37,7 +37,7 @@ import Toggler from '../../../../components/controls/Toggler';
import DropdownIcon from '../../../../components/icons-components/DropdownIcon';
import { isSonarCloud } from '../../../../helpers/system';
import { getPortfolioAdminUrl } from '../../../../helpers/urls';
-import { withAppState } from '../../../../components/withAppState';
+import { withAppState } from '../../../../components/hoc/withAppState';
interface Props {
appState: Pick<T.AppState, 'branchesEnabled'>;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavLicenseNotif.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavLicenseNotif.tsx
index 898633ae266..094142356d6 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavLicenseNotif.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavLicenseNotif.tsx
@@ -21,7 +21,7 @@ import * as React from 'react';
import { Link } from 'react-router';
import { translate } from '../../../../helpers/l10n';
import { isValidLicense } from '../../../../api/marketplace';
-import { withAppState } from '../../../../components/withAppState';
+import { withAppState } from '../../../../components/hoc/withAppState';
import { Alert } from '../../../../components/ui/Alert';
interface Props {
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
index 7779297d2f5..2b66e1c6cbb 100644
--- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
@@ -30,7 +30,7 @@ import {
} from '../../../../helpers/branches';
import { translate } from '../../../../helpers/l10n';
import DropdownIcon from '../../../../components/icons-components/DropdownIcon';
-import { withAppState } from '../../../../components/withAppState';
+import { withAppState } from '../../../../components/hoc/withAppState';
import { isSonarCloud } from '../../../../helpers/system';
const SETTINGS_URLS = [
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
index 72927c7d82e..addd5161955 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
+++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
@@ -27,7 +27,7 @@ import * as api from '../../../api/notifications';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { translate } from '../../../helpers/l10n';
import { Alert } from '../../../components/ui/Alert';
-import { withAppState } from '../../../components/withAppState';
+import { withAppState } from '../../../components/hoc/withAppState';
export interface Props {
appState: Pick<T.AppState, 'organizationsEnabled'>;
diff --git a/server/sonar-web/src/main/js/apps/code/code.css b/server/sonar-web/src/main/js/apps/code/code.css
index b7d48e5d534..26d6476f235 100644
--- a/server/sonar-web/src/main/js/apps/code/code.css
+++ b/server/sonar-web/src/main/js/apps/code/code.css
@@ -17,6 +17,18 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+.code-components .page-actions {
+ margin-top: -35px;
+}
+
+.code-components .boxed-group.search-results {
+ padding-top: 16px;
+}
+
+.code-components .boxed-group.search-results .page-actions {
+ margin-top: -50px;
+}
+
.code-breadcrumbs {
display: flex;
flex-wrap: wrap;
@@ -70,10 +82,6 @@
margin-bottom: 10px;
}
-.code-search-with-results + .code-components {
- display: none;
-}
-
.code-components-header {
position: sticky;
top: 95px;
diff --git a/server/sonar-web/src/main/js/apps/code/components/App.tsx b/server/sonar-web/src/main/js/apps/code/components/App.tsx
index ca8f1d392fb..7ecfe534e19 100644
--- a/server/sonar-web/src/main/js/apps/code/components/App.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/App.tsx
@@ -21,6 +21,7 @@ import * as React from 'react';
import * as classNames from 'classnames';
import { connect } from 'react-redux';
import Helmet from 'react-helmet';
+import { InjectedRouter } from 'react-router';
import { Location } from 'history';
import Components from './Components';
import Breadcrumbs from './Breadcrumbs';
@@ -34,6 +35,7 @@ import { fetchMetrics } from '../../../store/rootActions';
import { getMetrics } from '../../../store/rootReducer';
import { isSameBranchLike } from '../../../helpers/branches';
import { translate } from '../../../helpers/l10n';
+import { getProjectUrl, getCodeUrl } from '../../../helpers/urls';
import '../code.css';
interface StateToProps {
@@ -48,6 +50,7 @@ interface OwnProps {
branchLike?: T.BranchLike;
component: T.Component;
location: Pick<Location, 'query'>;
+ router: Pick<InjectedRouter, 'push'>;
}
type Props = StateToProps & DispatchToProps & OwnProps;
@@ -56,6 +59,7 @@ interface State {
baseComponent?: T.ComponentMeasure;
breadcrumbs: T.Breadcrumb[];
components?: T.ComponentMeasure[];
+ highlighted?: T.ComponentMeasure;
loading: boolean;
page: number;
searchResults?: T.ComponentMeasure[];
@@ -65,11 +69,12 @@ interface State {
export class App extends React.PureComponent<Props, State> {
mounted = false;
+
state: State = {
- loading: true,
breadcrumbs: [],
- total: 0,
- page: 0
+ loading: true,
+ page: 0,
+ total: 0
};
componentDidMount() {
@@ -94,59 +99,59 @@ export class App extends React.PureComponent<Props, State> {
this.mounted = false;
}
- handleComponentChange() {
- const { branchLike, component } = this.props;
-
- // we already know component's breadcrumbs,
- addComponentBreadcrumbs(component.key, component.breadcrumbs);
-
+ loadComponent = (componentKey: string) => {
this.setState({ loading: true });
- retrieveComponentChildren(component.key, component.qualifier, branchLike).then(() => {
- addComponent(component);
- if (this.mounted) {
- this.handleUpdate();
- }
- }, this.stopLoading);
- }
-
- loadComponent(componentKey: string) {
- this.setState({ loading: true });
-
retrieveComponent(componentKey, this.props.component.qualifier, this.props.branchLike).then(
r => {
if (this.mounted) {
if (['FIL', 'UTS'].includes(r.component.qualifier)) {
this.setState({
+ breadcrumbs: r.breadcrumbs,
+ components: r.components,
loading: false,
+ page: 0,
+ searchResults: undefined,
sourceViewer: r.component,
- breadcrumbs: r.breadcrumbs,
- searchResults: undefined
+ total: 0
});
} else {
this.setState({
- loading: false,
baseComponent: r.component,
- components: r.components,
breadcrumbs: r.breadcrumbs,
- total: r.total,
+ components: r.components,
+ loading: false,
page: r.page,
+ searchResults: undefined,
sourceViewer: undefined,
- searchResults: undefined
+ total: r.total
});
}
}
},
this.stopLoading
);
- }
+ };
- handleUpdate() {
- const { component, location } = this.props;
- const { selected } = location.query;
- const finalKey = selected || component.key;
+ stopLoading = () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ };
- this.loadComponent(finalKey);
- }
+ handleComponentChange = () => {
+ const { branchLike, component } = this.props;
+
+ // we already know component's breadcrumbs,
+ addComponentBreadcrumbs(component.key, component.breadcrumbs);
+
+ this.setState({ loading: true });
+ retrieveComponentChildren(component.key, component.qualifier, branchLike).then(() => {
+ addComponent(component);
+ if (this.mounted) {
+ this.handleUpdate();
+ }
+ }, this.stopLoading);
+ };
handleLoadMore = () => {
const { baseComponent, components, page } = this.state;
@@ -159,7 +164,7 @@ export class App extends React.PureComponent<Props, State> {
this.props.component.qualifier,
this.props.branchLike
).then(r => {
- if (this.mounted) {
+ if (this.mounted && r.components.length) {
this.setState({
components: [...components, ...r.components],
page: r.page,
@@ -169,19 +174,71 @@ export class App extends React.PureComponent<Props, State> {
}, this.stopLoading);
};
- stopLoading = () => {
- if (this.mounted) {
- this.setState({ loading: false });
+ handleGoToParent = () => {
+ const { branchLike, component } = this.props;
+ const { breadcrumbs = [] } = this.state;
+
+ if (breadcrumbs.length > 1) {
+ const parentComponent = breadcrumbs[breadcrumbs.length - 2];
+ this.props.router.push(getCodeUrl(component.key, branchLike, parentComponent.key));
+ this.setState({ highlighted: breadcrumbs[breadcrumbs.length - 1] });
}
};
+ handleHighlight = (highlighted: T.ComponentMeasure) => {
+ this.setState({ highlighted });
+ };
+
+ handleSearchClear = () => {
+ this.setState({ searchResults: undefined });
+ };
+
+ handleSearchResults = (searchResults: T.ComponentMeasure[] = []) => {
+ this.setState({ searchResults });
+ };
+
+ handleSelect = (component: T.ComponentMeasure) => {
+ const { branchLike, component: rootComponent } = this.props;
+
+ if (component.refKey) {
+ this.props.router.push(getProjectUrl(component.refKey));
+ } else {
+ this.props.router.push(getCodeUrl(rootComponent.key, branchLike, component.key));
+ }
+
+ this.setState({ highlighted: undefined });
+ };
+
+ handleUpdate = () => {
+ const { component, location } = this.props;
+ const { selected } = location.query;
+ const finalKey = selected || component.key;
+
+ this.loadComponent(finalKey);
+ };
+
render() {
const { branchLike, component, location } = this.props;
- const { loading, baseComponent, components, breadcrumbs, total, sourceViewer } = this.state;
- const shouldShowBreadcrumbs = breadcrumbs.length > 1;
+ const {
+ baseComponent,
+ breadcrumbs,
+ components = [],
+ highlighted,
+ loading,
+ total,
+ searchResults,
+ sourceViewer
+ } = this.state;
+
+ const showSearch = searchResults !== undefined;
+
+ const shouldShowBreadcrumbs = breadcrumbs.length > 1 && !showSearch;
+ const shouldShowComponentList =
+ sourceViewer === undefined && components.length > 0 && !showSearch;
const componentsClassName = classNames('boxed-group', 'spacer-top', {
- 'new-loading': loading
+ 'new-loading': loading,
+ 'search-results': showSearch
});
const defaultTitle =
@@ -194,7 +251,12 @@ export class App extends React.PureComponent<Props, State> {
<Suggestions suggestions="code" />
<Helmet title={sourceViewer !== undefined ? sourceViewer.name : defaultTitle} />
- <Search branchLike={branchLike} component={component} />
+ <Search
+ branchLike={branchLike}
+ component={component}
+ onSearchClear={this.handleSearchClear}
+ onSearchResults={this.handleSearchResults}
+ />
<div className="code-components">
{shouldShowBreadcrumbs && (
@@ -205,33 +267,54 @@ export class App extends React.PureComponent<Props, State> {
/>
)}
- {sourceViewer === undefined &&
- components !== undefined && (
+ {shouldShowComponentList && (
+ <>
<div className={componentsClassName}>
<Components
baseComponent={baseComponent}
branchLike={branchLike}
components={components}
+ cycle={true}
metrics={this.props.metrics}
+ onEndOfList={this.handleLoadMore}
+ onGoToParent={this.handleGoToParent}
+ onHighlight={this.handleHighlight}
+ onSelect={this.handleSelect}
rootComponent={component}
+ selected={highlighted}
/>
</div>
- )}
-
- {sourceViewer === undefined &&
- components !== undefined && (
<ListFooter count={components.length} loadMore={this.handleLoadMore} total={total} />
+ </>
+ )}
+
+ {showSearch &&
+ searchResults && (
+ <div className={componentsClassName}>
+ <Components
+ branchLike={this.props.branchLike}
+ components={searchResults}
+ metrics={{}}
+ onHighlight={this.handleHighlight}
+ onSelect={this.handleSelect}
+ rootComponent={component}
+ selected={highlighted}
+ />
+ </div>
)}
- {sourceViewer !== undefined && (
- <div className="spacer-top">
- <SourceViewerWrapper
- branchLike={branchLike}
- component={sourceViewer.key}
- location={location}
- />
- </div>
- )}
+ {sourceViewer !== undefined &&
+ !showSearch && (
+ <div className="spacer-top">
+ <SourceViewerWrapper
+ branchLike={branchLike}
+ component={sourceViewer.key}
+ isFile={true}
+ location={location}
+ onGoToParent={this.handleGoToParent}
+ />
+ </div>
+ )}
</div>
</div>
);
diff --git a/server/sonar-web/src/main/js/apps/code/components/Component.tsx b/server/sonar-web/src/main/js/apps/code/components/Component.tsx
index e0d9d559e96..d5262dabeb9 100644
--- a/server/sonar-web/src/main/js/apps/code/components/Component.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/Component.tsx
@@ -23,9 +23,7 @@ import ComponentName from './ComponentName';
import ComponentMeasure from './ComponentMeasure';
import ComponentPin from './ComponentPin';
import { WorkspaceContext } from '../../../components/workspace/context';
-
-const TOP_OFFSET = 200;
-const BOTTOM_OFFSET = 10;
+import { withScrollTo } from '../../../components/hoc/withScrollTo';
interface Props {
branchLike?: T.BranchLike;
@@ -38,40 +36,7 @@ interface Props {
selected?: boolean;
}
-export default class Component extends React.PureComponent<Props> {
- node?: HTMLElement | null;
-
- componentDidMount() {
- this.handleUpdate();
- }
-
- componentDidUpdate() {
- this.handleUpdate();
- }
-
- handleUpdate() {
- const { selected } = this.props;
-
- // scroll viewport so the current selected component is visible
- if (selected) {
- setTimeout(() => {
- this.handleScroll();
- }, 0);
- }
- }
-
- handleScroll() {
- if (this.node) {
- 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);
- }
- }
- }
-
+export class Component extends React.PureComponent<Props> {
render() {
const {
branchLike,
@@ -87,7 +52,7 @@ export default class Component extends React.PureComponent<Props> {
const isFile = component.qualifier === 'FIL' || component.qualifier === 'UTS';
return (
- <tr className={classNames({ selected })} ref={node => (this.node = node)}>
+ <tr className={classNames({ selected })}>
<td className="blank" />
<td className="thin nowrap">
<span className="spacer-right">
@@ -126,3 +91,5 @@ export default class Component extends React.PureComponent<Props> {
);
}
}
+
+export default withScrollTo(Component);
diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx
index c19f065e912..8269685b0d3 100644
--- a/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/ComponentMeasure.tsx
@@ -27,21 +27,24 @@ interface Props {
metric: T.Metric;
}
-export default function ComponentMeasure({ component, metric }: Props) {
- const isProject = component.qualifier === 'TRK';
- const isReleasability = metric.key === 'releasability_rating';
+export default class ComponentMeasure extends React.PureComponent<Props> {
+ render() {
+ const { component, metric } = this.props;
+ const isProject = component.qualifier === 'TRK';
+ const isReleasability = metric.key === 'releasability_rating';
- const finalMetricKey = isProject && isReleasability ? 'alert_status' : metric.key;
- const finalMetricType = isProject && isReleasability ? 'LEVEL' : metric.type;
+ const finalMetricKey = isProject && isReleasability ? 'alert_status' : metric.key;
+ const finalMetricType = isProject && isReleasability ? 'LEVEL' : metric.type;
- const measure =
- Array.isArray(component.measures) &&
- component.measures.find(measure => measure.metric === finalMetricKey);
+ const measure =
+ Array.isArray(component.measures) &&
+ component.measures.find(measure => measure.metric === finalMetricKey);
- if (!measure) {
- return <span />;
- }
+ if (!measure) {
+ return <span />;
+ }
- const value = isDiffMetric(metric.key) ? getLeakValue(measure) : measure.value;
- return <Measure metricKey={finalMetricKey} metricType={finalMetricType} value={value} />;
+ const value = isDiffMetric(metric.key) ? getLeakValue(measure) : measure.value;
+ return <Measure metricKey={finalMetricKey} metricType={finalMetricType} value={value} />;
+ }
}
diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentName.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentName.tsx
index d8b58d0bd8c..ed43ecf8d5f 100644
--- a/server/sonar-web/src/main/js/apps/code/components/ComponentName.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/ComponentName.tsx
@@ -58,66 +58,68 @@ interface Props {
rootComponent: T.ComponentMeasure;
}
-export default function ComponentName(props: Props) {
- const { branchLike, component, rootComponent, previous, canBrowse = false } = props;
- const areBothDirs = component.qualifier === 'DIR' && previous && previous.qualifier === 'DIR';
- const prefix =
- areBothDirs && previous !== undefined
- ? mostCommitPrefix([component.name + '/', previous.name + '/'])
- : '';
- const name = prefix ? (
- <span>
- <span style={{ color: theme.secondFontColor }}>{prefix}</span>
- <span>{component.name.substr(prefix.length)}</span>
- </span>
- ) : (
- component.name
- );
-
- let inner = null;
-
- if (component.refKey && component.qualifier !== 'SVW') {
- const branch = rootComponent.qualifier === 'APP' ? { branch: component.branch } : {};
- inner = (
- <Link
- className="link-with-icon"
- to={{ pathname: '/dashboard', query: { id: component.refKey, ...branch } }}>
- <QualifierIcon qualifier={component.qualifier} /> <span>{name}</span>
- </Link>
- );
- } else if (canBrowse) {
- const query = { id: rootComponent.key, ...getBranchLikeQuery(branchLike) };
- if (component.key !== rootComponent.key) {
- Object.assign(query, { selected: component.key });
- }
- inner = (
- <Link className="link-with-icon" to={{ pathname: '/code', query }}>
- <QualifierIcon qualifier={component.qualifier} /> <span>{name}</span>
- </Link>
- );
- } else {
- inner = (
+export default class ComponentName extends React.PureComponent<Props> {
+ render() {
+ const { branchLike, component, rootComponent, previous, canBrowse = false } = this.props;
+ const areBothDirs = component.qualifier === 'DIR' && previous && previous.qualifier === 'DIR';
+ const prefix =
+ areBothDirs && previous !== undefined
+ ? mostCommitPrefix([component.name + '/', previous.name + '/'])
+ : '';
+ const name = prefix ? (
<span>
- <QualifierIcon qualifier={component.qualifier} /> {name}
+ <span style={{ color: theme.secondFontColor }}>{prefix}</span>
+ <span>{component.name.substr(prefix.length)}</span>
</span>
+ ) : (
+ component.name
);
- }
- if (rootComponent.qualifier === 'APP') {
- inner = (
- <>
- {inner}
- {component.branch ? (
- <>
- <LongLivingBranchIcon className="spacer-left little-spacer-right" />
- <span className="note">{component.branch}</span>
- </>
- ) : (
- <span className="spacer-left outline-badge">{translate('branches.main_branch')}</span>
- )}
- </>
- );
- }
+ let inner = null;
+
+ if (component.refKey && component.qualifier !== 'SVW') {
+ const branch = rootComponent.qualifier === 'APP' ? { branch: component.branch } : {};
+ inner = (
+ <Link
+ className="link-with-icon"
+ to={{ pathname: '/dashboard', query: { id: component.refKey, ...branch } }}>
+ <QualifierIcon qualifier={component.qualifier} /> <span>{name}</span>
+ </Link>
+ );
+ } else if (canBrowse) {
+ const query = { id: rootComponent.key, ...getBranchLikeQuery(branchLike) };
+ if (component.key !== rootComponent.key) {
+ Object.assign(query, { selected: component.key });
+ }
+ inner = (
+ <Link className="link-with-icon" to={{ pathname: '/code', query }}>
+ <QualifierIcon qualifier={component.qualifier} /> <span>{name}</span>
+ </Link>
+ );
+ } else {
+ inner = (
+ <span>
+ <QualifierIcon qualifier={component.qualifier} /> {name}
+ </span>
+ );
+ }
- return <Truncated title={getTooltip(component)}>{inner}</Truncated>;
+ if (rootComponent.qualifier === 'APP') {
+ inner = (
+ <>
+ {inner}
+ {component.branch ? (
+ <>
+ <LongLivingBranchIcon className="spacer-left little-spacer-right" />
+ <span className="note">{component.branch}</span>
+ </>
+ ) : (
+ <span className="spacer-left outline-badge">{translate('branches.main_branch')}</span>
+ )}
+ </>
+ );
+ }
+
+ return <Truncated title={getTooltip(component)}>{inner}</Truncated>;
+ }
}
diff --git a/server/sonar-web/src/main/js/apps/code/components/Components.tsx b/server/sonar-web/src/main/js/apps/code/components/Components.tsx
index 6c02bb835f5..506a3d4fe83 100644
--- a/server/sonar-web/src/main/js/apps/code/components/Components.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/Components.tsx
@@ -22,8 +22,9 @@ import * as classNames from 'classnames';
import Component from './Component';
import ComponentsEmpty from './ComponentsEmpty';
import ComponentsHeader from './ComponentsHeader';
-import { isDefined } from '../../../helpers/types';
+import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation';
import { getCodeMetrics, showLeakMeasure } from '../utils';
+import { isDefined } from '../../../helpers/types';
interface Props {
baseComponent?: T.ComponentMeasure;
@@ -34,64 +35,68 @@ interface Props {
selected?: T.ComponentMeasure;
}
-export default function Components(props: Props) {
- const { baseComponent, branchLike, components, rootComponent, selected } = props;
- const metricKeys = getCodeMetrics(rootComponent.qualifier, branchLike);
- const metrics = metricKeys.map(metric => props.metrics[metric]).filter(isDefined);
- const isLeak = Boolean(baseComponent && showLeakMeasure(branchLike));
- return (
- <table className="data boxed-padding zebra">
- {baseComponent && (
- <ComponentsHeader
- baseComponent={baseComponent}
- isLeak={isLeak}
- metrics={metricKeys}
- rootComponent={rootComponent}
- />
- )}
- {baseComponent && (
- <tbody>
- <Component
- branchLike={branchLike}
- component={baseComponent}
+export class Components extends React.PureComponent<Props> {
+ render() {
+ const { baseComponent, branchLike, components, rootComponent, selected } = this.props;
+ const metricKeys = getCodeMetrics(rootComponent.qualifier, branchLike);
+ const metrics = metricKeys.map(metric => this.props.metrics[metric]).filter(isDefined);
+ const isLeak = Boolean(baseComponent && showLeakMeasure(branchLike));
+ return (
+ <table className="data boxed-padding zebra">
+ {baseComponent && (
+ <ComponentsHeader
+ baseComponent={baseComponent}
isLeak={isLeak}
- key={baseComponent.key}
- metrics={metrics}
+ metrics={metricKeys}
rootComponent={rootComponent}
/>
- <tr className="blank">
- <td colSpan={3}>&nbsp;</td>
- <td className={classNames({ leak: isLeak })} colSpan={10}>
- {' '}
- &nbsp;{' '}
- </td>
- </tr>
- </tbody>
- )}
- <tbody>
- {components.length ? (
- components.map((component, index, list) => (
+ )}
+ {baseComponent && (
+ <tbody>
<Component
branchLike={branchLike}
- canBrowse={true}
- component={component}
+ component={baseComponent}
isLeak={isLeak}
- key={component.key}
+ key={baseComponent.key}
metrics={metrics}
- previous={index > 0 ? list[index - 1] : undefined}
rootComponent={rootComponent}
- selected={component === selected}
/>
- ))
- ) : (
- <ComponentsEmpty isLeak={isLeak} />
+ <tr className="blank">
+ <td colSpan={3}>&nbsp;</td>
+ <td className={classNames({ leak: isLeak })} colSpan={10}>
+ {' '}
+ &nbsp;{' '}
+ </td>
+ </tr>
+ </tbody>
)}
+ <tbody>
+ {components.length ? (
+ components.map((component, index, list) => (
+ <Component
+ branchLike={branchLike}
+ canBrowse={true}
+ component={component}
+ isLeak={isLeak}
+ key={component.key}
+ metrics={metrics}
+ previous={index > 0 ? list[index - 1] : undefined}
+ rootComponent={rootComponent}
+ selected={selected && component.key === selected.key}
+ />
+ ))
+ ) : (
+ <ComponentsEmpty isLeak={isLeak} />
+ )}
- <tr className="blank">
- <td colSpan={3} />
- <td className={classNames({ leak: isLeak })} colSpan={10} />
- </tr>
- </tbody>
- </table>
- );
+ <tr className="blank">
+ <td colSpan={3} />
+ <td className={classNames({ leak: isLeak })} colSpan={10} />
+ </tr>
+ </tbody>
+ </table>
+ );
+ }
}
+
+export default withKeyboardNavigation(Components);
diff --git a/server/sonar-web/src/main/js/apps/code/components/Search.tsx b/server/sonar-web/src/main/js/apps/code/components/Search.tsx
index 0df928e7940..1f71cc3eda8 100644
--- a/server/sonar-web/src/main/js/apps/code/components/Search.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/Search.tsx
@@ -18,27 +18,25 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
-import * as classNames from 'classnames';
-import Components from './Components';
import { getTree } from '../../../api/components';
import SearchBox from '../../../components/controls/SearchBox';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { getBranchLikeQuery } from '../../../helpers/branches';
import { translate } from '../../../helpers/l10n';
-import { getProjectUrl } from '../../../helpers/urls';
import { withRouter, Router, Location } from '../../../components/hoc/withRouter';
interface Props {
branchLike?: T.BranchLike;
component: T.ComponentMeasure;
location: Location;
+ onSearchClear: () => void;
+ onSearchResults: (results?: T.ComponentMeasure[]) => void;
router: Pick<Router, 'push'>;
}
interface State {
query: string;
loading: boolean;
- results?: T.ComponentMeasure[];
- selectedIndex?: number;
}
class Search extends React.PureComponent<Props, State> {
@@ -57,10 +55,9 @@ class Search extends React.PureComponent<Props, State> {
if (nextProps.location !== this.props.location) {
this.setState({
query: '',
- loading: false,
- results: undefined,
- selectedIndex: undefined
+ loading: false
});
+ this.props.onSearchClear();
}
}
@@ -68,52 +65,14 @@ class Search extends React.PureComponent<Props, State> {
this.mounted = false;
}
- handleSelectNext() {
- const { selectedIndex, results } = this.state;
- if (results && selectedIndex !== undefined && selectedIndex < results.length - 1) {
- this.setState({ selectedIndex: selectedIndex + 1 });
- }
- }
-
- handleSelectPrevious() {
- const { selectedIndex, results } = this.state;
- if (results && selectedIndex !== undefined && selectedIndex > 0) {
- this.setState({ selectedIndex: selectedIndex - 1 });
- }
- }
-
- handleSelectCurrent() {
- const { branchLike, component } = this.props;
- const { results, selectedIndex } = this.state;
- if (results && selectedIndex !== undefined) {
- const selected = results[selectedIndex];
-
- if (selected.refKey) {
- this.props.router.push(getProjectUrl(selected.refKey));
- } else {
- this.props.router.push({
- pathname: '/code',
- query: { id: component.key, selected: selected.key, ...getBranchLikeQuery(branchLike) }
- });
- }
- }
- }
-
handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.keyCode) {
case 13:
- event.preventDefault();
- this.handleSelectCurrent();
- break;
case 38:
- event.preventDefault();
- this.handleSelectPrevious();
- break;
case 40:
event.preventDefault();
- this.handleSelectNext();
+ event.currentTarget.blur();
break;
- default: // do nothing
}
};
@@ -135,10 +94,9 @@ class Search extends React.PureComponent<Props, State> {
.then(r => {
if (this.mounted) {
this.setState({
- results: r.components,
- selectedIndex: r.components.length > 0 ? 0 : undefined,
loading: false
});
+ this.props.onSearchResults(r.components);
}
})
.catch(() => {
@@ -152,7 +110,7 @@ class Search extends React.PureComponent<Props, State> {
handleQueryChange = (query: string) => {
this.setState({ query });
if (query.length === 0) {
- this.setState({ results: undefined });
+ this.props.onSearchClear();
} else {
this.handleSearch(query);
}
@@ -160,15 +118,11 @@ class Search extends React.PureComponent<Props, State> {
render() {
const { component } = this.props;
- const { loading, selectedIndex, results } = this.state;
- const selected = selectedIndex !== undefined && results ? results[selectedIndex] : undefined;
- const containerClassName = classNames('code-search', {
- 'code-search-with-results': Boolean(results)
- });
+ const { loading } = this.state;
const isPortfolio = ['VW', 'SVW', 'APP'].includes(component.qualifier);
return (
- <div className={containerClassName} id="code-search">
+ <div className="code-search" id="code-search">
<SearchBox
minLength={3}
onChange={this.handleQueryChange}
@@ -178,20 +132,7 @@ class Search extends React.PureComponent<Props, State> {
)}
value={this.state.query}
/>
- {loading && <i className="spinner spacer-left" />}
-
- {results && (
- <div className="boxed-group spacer-top">
- <div className="big-spacer-top" />
- <Components
- branchLike={this.props.branchLike}
- components={results}
- metrics={{}}
- rootComponent={component}
- selected={selected}
- />
- </div>
- )}
+ <DeferredSpinner className="spacer-left" loading={loading} />
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx
index a3797f28d28..fd7bfe9a91d 100644
--- a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx
@@ -20,6 +20,7 @@
import * as React from 'react';
import { Location } from 'history';
import SourceViewer from '../../../components/SourceViewer/SourceViewer';
+import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation';
import { scrollToElement } from '../../../helpers/scrolling';
interface Props {
@@ -28,10 +29,11 @@ interface Props {
location: Pick<Location, 'query'>;
}
-export default function SourceViewerWrapper({ branchLike, component, location }: Props) {
- const { line } = location.query;
+export class SourceViewerWrapper extends React.PureComponent<Props> {
+ scrollToLine = () => {
+ const { location } = this.props;
+ const { line } = location.query;
- const scrollToLine = () => {
if (line) {
const row = document.querySelector(`.source-line[data-line-number="${line}"]`);
if (row) {
@@ -40,15 +42,21 @@ export default function SourceViewerWrapper({ branchLike, component, location }:
}
};
- const finalLine = line ? Number(line) : undefined;
+ render() {
+ const { branchLike, component, location } = this.props;
+ const { line } = location.query;
+ const finalLine = line ? Number(line) : undefined;
- return (
- <SourceViewer
- aroundLine={finalLine}
- branchLike={branchLike}
- component={component}
- highlightedLine={finalLine}
- onLoaded={scrollToLine}
- />
- );
+ return (
+ <SourceViewer
+ aroundLine={finalLine}
+ branchLike={branchLike}
+ component={component}
+ highlightedLine={finalLine}
+ onLoaded={this.scrollToLine}
+ />
+ );
+ }
}
+
+export default withKeyboardNavigation(SourceViewerWrapper);
diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/App-test.tsx
index df23aa5731d..dcb2a2ddc70 100644
--- a/server/sonar-web/src/main/js/apps/code/components/__tests__/App-test.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/App-test.tsx
@@ -20,7 +20,7 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import { App } from '../App';
-import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { waitAndUpdate, mockRouter } from '../../../../helpers/testUtils';
import { retrieveComponent } from '../../utils';
jest.mock('../../utils', () => ({
@@ -88,6 +88,7 @@ const getWrapper = () => {
fetchMetrics={jest.fn()}
location={{ query: { branch: 'b', id: 'foo', line: '7' } }}
metrics={METRICS}
+ router={mockRouter()}
/>
);
};
diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/Components-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/Components-test.tsx
index cd3dc9fb961..ab94ce89c03 100644
--- a/server/sonar-web/src/main/js/apps/code/components/__tests__/Components-test.tsx
+++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/Components-test.tsx
@@ -19,7 +19,7 @@
*/
import * as React from 'react';
import { shallow } from 'enzyme';
-import Components from '../Components';
+import { Components } from '../Components';
const COMPONENT = { key: 'foo', name: 'Foo', qualifier: 'TRK' };
const PORTFOLIO = { key: 'bar', name: 'Bar', qualifier: 'VW' };
diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Components-test.tsx.snap b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Components-test.tsx.snap
index 574a4d74e20..9f8963ef37d 100644
--- a/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Components-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/__snapshots__/Components-test.tsx.snap
@@ -32,7 +32,7 @@ exports[`renders correctly 1`] = `
}
/>
<tbody>
- <Component
+ <withScrollTo(Component)
component={
Object {
"key": "foo",
@@ -79,7 +79,7 @@ exports[`renders correctly 1`] = `
</tr>
</tbody>
<tbody>
- <Component
+ <withScrollTo(Component)
canBrowse={true}
component={
Object {
@@ -107,7 +107,6 @@ exports[`renders correctly 1`] = `
"qualifier": "TRK",
}
}
- selected={false}
/>
<tr
className="blank"
@@ -129,7 +128,7 @@ exports[`renders correctly for a search 1`] = `
className="data boxed-padding zebra"
>
<tbody>
- <Component
+ <withScrollTo(Component)
canBrowse={true}
component={
Object {
@@ -157,7 +156,6 @@ exports[`renders correctly for a search 1`] = `
"qualifier": "TRK",
}
}
- selected={false}
/>
<tr
className="blank"
@@ -206,7 +204,7 @@ exports[`renders correctly for leak 1`] = `
}
/>
<tbody>
- <Component
+ <withScrollTo(Component)
branchLike={
Object {
"isMain": false,
@@ -252,7 +250,7 @@ exports[`renders correctly for leak 1`] = `
</tr>
</tbody>
<tbody>
- <Component
+ <withScrollTo(Component)
branchLike={
Object {
"isMain": false,
@@ -279,7 +277,6 @@ exports[`renders correctly for leak 1`] = `
"qualifier": "TRK",
}
}
- selected={false}
/>
<tr
className="blank"
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsIssues.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsIssues.tsx
index e5d7fce9c7f..5a6dbfc9f85 100644
--- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsIssues.tsx
+++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsIssues.tsx
@@ -25,7 +25,7 @@ import { getFacet } from '../../../api/issues';
import { getIssuesUrl } from '../../../helpers/urls';
import { formatMeasure } from '../../../helpers/measures';
import { translate } from '../../../helpers/l10n';
-import { withAppState } from '../../../components/withAppState';
+import { withAppState } from '../../../components/hoc/withAppState';
interface Props {
appState: Pick<T.AppState, 'branchesEnabled'>;
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
index e0c16962bea..a32762e9998 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
+++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
@@ -23,7 +23,7 @@ import Breadcrumbs from './Breadcrumbs';
import MeasureContentHeader from './MeasureContentHeader';
import MeasureHeader from './MeasureHeader';
import MeasureViewSelect from './MeasureViewSelect';
-import PageActions from './PageActions';
+import PageActions from '../../../components/ui/PageActions';
import { complementary } from '../config/complementary';
import CodeView from '../drilldown/CodeView';
import FilesView from '../drilldown/FilesView';
@@ -364,8 +364,8 @@ export default class MeasureContent extends React.PureComponent<Props, State> {
}
isFile={isFile}
paging={this.state.paging}
+ showShortcuts={['list', 'tree'].includes(view)}
totalLoadedComponents={this.state.components.length}
- view={view}
/>
</div>
}
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx
index cc871ea922f..f0455dc8c5b 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx
+++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx
@@ -21,7 +21,7 @@ import * as React from 'react';
import Breadcrumbs from './Breadcrumbs';
import LeakPeriodLegend from './LeakPeriodLegend';
import MeasureContentHeader from './MeasureContentHeader';
-import PageActions from './PageActions';
+import PageActions from '../../../components/ui/PageActions';
import BubbleChart from '../drilldown/BubbleChart';
import SourceViewer from '../../../components/SourceViewer/SourceViewer';
import { getComponentLeaves } from '../../../api/components';
diff --git a/server/sonar-web/src/main/js/apps/component-measures/style.css b/server/sonar-web/src/main/js/apps/component-measures/style.css
index 853316d50e1..3b0b7feeb01 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/style.css
+++ b/server/sonar-web/src/main/js/apps/component-measures/style.css
@@ -167,6 +167,10 @@
white-space: nowrap;
}
+.measure-content-header-right .page-actions {
+ margin-bottom: 0;
+}
+
.measure-content-header-right {
margin-left: calc(2 * var(--gridSize));
white-space: nowrap;
diff --git a/server/sonar-web/src/main/js/apps/organizations/forSingleOrganization.tsx b/server/sonar-web/src/main/js/apps/organizations/forSingleOrganization.tsx
index e5d782f016d..71b81cc0942 100644
--- a/server/sonar-web/src/main/js/apps/organizations/forSingleOrganization.tsx
+++ b/server/sonar-web/src/main/js/apps/organizations/forSingleOrganization.tsx
@@ -21,6 +21,7 @@ import * as React from 'react';
import { connect } from 'react-redux';
import { withRouter, WithRouterProps } from 'react-router';
import { areThereCustomOrganizations, Store } from '../../store/rootReducer';
+import { getWrappedDisplayName } from '../../components/hoc/utils';
type ReactComponent<P> = React.ComponentClass<P> | React.StatelessComponent<P>;
@@ -30,7 +31,10 @@ export default function forSingleOrganization<P>(ComposedComponent: ReactCompone
}
class ForSingleOrganization extends React.Component<StateProps & WithRouterProps> {
- static displayName = `forSingleOrganization(${ComposedComponent.displayName})}`;
+ static displayName = getWrappedDisplayName(
+ ComposedComponent as React.ComponentClass,
+ 'forSingleOrganization'
+ );
render() {
const { customOrganizations, router, ...other } = this.props;
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/apps/component-measures/components/FilesCounter.tsx b/server/sonar-web/src/main/js/components/ui/FilesCounter.tsx
index 72ee0175fd4..09ef95d5005 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/FilesCounter.tsx
+++ b/server/sonar-web/src/main/js/components/ui/FilesCounter.tsx
@@ -18,8 +18,8 @@
* 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';
+import { translate } from '../../helpers/l10n';
+import { formatMeasure } from '../../helpers/measures';
interface Props {
className?: string;
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.tsx b/server/sonar-web/src/main/js/components/ui/PageActions.tsx
index 32dc2706fe7..4e6d6386c2a 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/PageActions.tsx
+++ b/server/sonar-web/src/main/js/components/ui/PageActions.tsx
@@ -19,40 +19,43 @@
*/
import * as React from 'react';
import FilesCounter from './FilesCounter';
-import { translate } from '../../../helpers/l10n';
-import { View } from '../utils';
+import { translate } from '../../helpers/l10n';
interface Props {
current?: number;
isFile?: boolean;
paging?: T.Paging;
+ showPaging?: boolean;
+ showShortcuts?: boolean;
totalLoadedComponents?: number;
- view?: View;
}
export default function PageActions(props: Props) {
- const { isFile, paging, totalLoadedComponents } = props;
- const showShortcuts = props.view && ['list', 'tree'].includes(props.view);
+ 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="display-flex-center">
+ <div className="page-actions display-flex-center">
{!isFile && showShortcuts && renderShortcuts()}
- {isFile && paging && renderFileShortcuts()}
- <div className="measure-details-page-actions nowrap">
- {paging != null && (
- <FilesCounter
- className="spacer-left"
- current={props.current}
- total={isFile && totalLoadedComponents != null ? totalLoadedComponents : paging.total}
- />
- )}
- </div>
+ {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 big-spacer-right nowrap">
+ <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>
@@ -70,7 +73,7 @@ function renderShortcuts() {
function renderFileShortcuts() {
return (
- <span className="note spacer-right nowrap">
+ <span className="note nowrap">
<span>
<span className="shortcut-button little-spacer-right">j</span>
<span className="shortcut-button little-spacer-right">k</span>
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/FilesCounter-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx
index 374631e3762..374631e3762 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/FilesCounter-test.tsx
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/FilesCounter-test.tsx
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/PageActions-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx
index e3febe445c1..edefa9311ee 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/PageActions-test.tsx
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/PageActions-test.tsx
@@ -29,12 +29,14 @@ const PAGING = {
it('should display correctly for a project', () => {
expect(
- shallow(<PageActions isFile={false} totalLoadedComponents={20} view="list" />)
+ shallow(<PageActions isFile={false} showShortcuts={true} totalLoadedComponents={20} />)
).toMatchSnapshot();
});
it('should display correctly for a file', () => {
- const wrapper = shallow(<PageActions isFile={true} totalLoadedComponents={10} view="tree" />);
+ const wrapper = shallow(
+ <PageActions isFile={true} showShortcuts={true} totalLoadedComponents={10} />
+ );
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ paging: { total: 100 } });
expect(wrapper).toMatchSnapshot();
@@ -42,7 +44,7 @@ it('should display correctly for a file', () => {
it('should not display shortcuts for treemap', () => {
expect(
- shallow(<PageActions isFile={false} totalLoadedComponents={20} view="treemap" />)
+ shallow(<PageActions isFile={false} showShortcuts={false} totalLoadedComponents={20} />)
).toMatchSnapshot();
});
@@ -53,8 +55,8 @@ it('should display the total of files', () => {
current={12}
isFile={false}
paging={PAGING}
+ showShortcuts={false}
totalLoadedComponents={20}
- view="treemap"
/>
)
).toMatchSnapshot();
@@ -64,8 +66,8 @@ it('should display the total of files', () => {
current={12}
isFile={true}
paging={PAGING}
+ showShortcuts={true}
totalLoadedComponents={20}
- view="list"
/>
)
).toMatchSnapshot();
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/FilesCounter-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap
index bb01a6121da..bb01a6121da 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/FilesCounter-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/FilesCounter-test.tsx.snap
diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/PageActions-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap
index 002f43d4c7e..d76eabe318c 100644
--- a/server/sonar-web/src/main/js/apps/component-measures/components/__tests__/__snapshots__/PageActions-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/PageActions-test.tsx.snap
@@ -2,20 +2,16 @@
exports[`should display correctly for a file 1`] = `
<div
- className="display-flex-center"
->
- <div
- className="measure-details-page-actions nowrap"
- />
-</div>
+ className="page-actions display-flex-center"
+/>
`;
exports[`should display correctly for a file 2`] = `
<div
- className="display-flex-center"
+ className="page-actions display-flex-center"
>
<span
- className="note spacer-right nowrap"
+ className="note nowrap"
>
<span>
<span
@@ -35,7 +31,7 @@ exports[`should display correctly for a file 2`] = `
className="measure-details-page-actions nowrap"
>
<FilesCounter
- className="spacer-left"
+ className="big-spacer-left"
total={10}
/>
</div>
@@ -44,10 +40,10 @@ exports[`should display correctly for a file 2`] = `
exports[`should display correctly for a project 1`] = `
<div
- className="display-flex-center"
+ className="page-actions display-flex-center"
>
<span
- className="note big-spacer-right nowrap"
+ className="note nowrap"
>
<span
className="big-spacer-right"
@@ -78,21 +74,18 @@ exports[`should display correctly for a project 1`] = `
component_measures.to_navigate
</span>
</span>
- <div
- className="measure-details-page-actions nowrap"
- />
</div>
`;
exports[`should display the total of files 1`] = `
<div
- className="display-flex-center"
+ className="page-actions display-flex-center"
>
<div
className="measure-details-page-actions nowrap"
>
<FilesCounter
- className="spacer-left"
+ className="big-spacer-left"
current={12}
total={120}
/>
@@ -102,10 +95,10 @@ exports[`should display the total of files 1`] = `
exports[`should display the total of files 2`] = `
<div
- className="display-flex-center"
+ className="page-actions display-flex-center"
>
<span
- className="note spacer-right nowrap"
+ className="note nowrap"
>
<span>
<span
@@ -125,7 +118,7 @@ exports[`should display the total of files 2`] = `
className="measure-details-page-actions nowrap"
>
<FilesCounter
- className="spacer-left"
+ className="big-spacer-left"
current={12}
total={20}
/>
@@ -135,10 +128,6 @@ exports[`should display the total of files 2`] = `
exports[`should not display shortcuts for treemap 1`] = `
<div
- className="display-flex-center"
->
- <div
- className="measure-details-page-actions nowrap"
- />
-</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';
diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts
index 210bd412192..88a810d66aa 100644
--- a/server/sonar-web/src/main/js/helpers/testUtils.ts
+++ b/server/sonar-web/src/main/js/helpers/testUtils.ts
@@ -68,8 +68,29 @@ export function change(element: ShallowWrapper | ReactWrapper, value: string, ev
}
}
-export function keydown(keyCode: number): void {
- const event = new KeyboardEvent('keydown', { keyCode } as KeyboardEventInit);
+export const KEYCODE_MAP: { [keycode: number]: string } = {
+ 13: 'enter',
+ 37: 'left',
+ 38: 'up',
+ 39: 'right',
+ 40: 'down',
+ 74: 'j',
+ 75: 'k'
+};
+
+export function keydown(key: number | string): void {
+ let keyCode;
+ if (typeof key === 'number') {
+ keyCode = key;
+ } else {
+ const mapped = Object.entries(KEYCODE_MAP).find(([_, value]) => value === key);
+ if (!mapped) {
+ throw new Error(`Cannot map key "${key}" to a keyCode!`);
+ }
+ keyCode = mapped[0];
+ }
+
+ const event = new KeyboardEvent('keydown', { keyCode, which: keyCode } as KeyboardEventInit);
document.dispatchEvent(event);
}