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 | |
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')
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}> </td> - <td className={classNames({ leak: isLeak })} colSpan={10}> - {' '} - {' '} - </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}> </td> + <td className={classNames({ leak: isLeak })} colSpan={10}> + {' '} + {' '} + </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); } |