]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9702 Build UI for short-lived branches (#2371)
authorStas Vilchik <stas.vilchik@sonarsource.com>
Thu, 17 Aug 2017 19:39:59 +0000 (21:39 +0200)
committerJanos Gyerik <janos.gyerik@sonarsource.com>
Tue, 12 Sep 2017 09:34:36 +0000 (11:34 +0200)
95 files changed:
server/sonar-web/src/main/js/api/branches.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/components.ts
server/sonar-web/src/main/js/api/nav.ts
server/sonar-web/src/main/js/app/components/ProjectAdminContainer.js
server/sonar-web/src/main/js/app/components/ProjectContainer.js [deleted file]
server/sonar-web/src/main/js/app/components/ProjectContainer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.js [deleted file]
server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.js
server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.js [deleted file]
server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.css
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranch.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.js [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.js [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranch-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenuItem-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.js [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMeta-test.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranch-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenuItem-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap [deleted file]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/search/Search.css
server/sonar-web/src/main/js/app/components/search/Search.js
server/sonar-web/src/main/js/apps/background-tasks/components/BackgroundTasksApp.js
server/sonar-web/src/main/js/apps/background-tasks/components/TaskStatus.js
server/sonar-web/src/main/js/apps/code/components/App.js
server/sonar-web/src/main/js/apps/code/components/Component.js
server/sonar-web/src/main/js/apps/code/components/ComponentDetach.js
server/sonar-web/src/main/js/apps/code/components/ComponentName.js
server/sonar-web/src/main/js/apps/code/components/ComponentPin.js
server/sonar-web/src/main/js/apps/code/components/Search.js
server/sonar-web/src/main/js/apps/code/routes.ts
server/sonar-web/src/main/js/apps/code/utils.js
server/sonar-web/src/main/js/apps/component-measures/components/AppContainer.js
server/sonar-web/src/main/js/apps/custom-measures/components/CustomMeasuresAppContainer.js
server/sonar-web/src/main/js/apps/custom-measures/routes.ts
server/sonar-web/src/main/js/apps/issues/components/App.js
server/sonar-web/src/main/js/apps/issues/components/AppContainer.js
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.js
server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.js
server/sonar-web/src/main/js/apps/overview/components/App.js
server/sonar-web/src/main/js/apps/overview/components/AppContainer.js [deleted file]
server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js
server/sonar-web/src/main/js/apps/overview/meta/Meta.js
server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js
server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTagsSelector-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap
server/sonar-web/src/main/js/apps/overview/routes.ts
server/sonar-web/src/main/js/apps/project-admin/deletion/Deletion.js
server/sonar-web/src/main/js/apps/project-admin/key/Key.js
server/sonar-web/src/main/js/apps/project-admin/links/Links.js
server/sonar-web/src/main/js/apps/project-admin/quality-gate/QualityGate.js
server/sonar-web/src/main/js/apps/project-admin/quality-profiles/QualityProfiles.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppContainer.js
server/sonar-web/src/main/js/apps/projectActivity/routes.ts
server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js [deleted file]
server/sonar-web/src/main/js/apps/settings/components/AppContainer.js
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js
server/sonar-web/src/main/js/components/icons-components/BranchIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/icons-components/PendingIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/nav/ContextNavBar.css
server/sonar-web/src/main/js/components/nav/ContextNavBar.js [deleted file]
server/sonar-web/src/main/js/components/nav/ContextNavBar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/nav/NavBar.js [deleted file]
server/sonar-web/src/main/js/components/nav/NavBar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/nav/NavBarTabs.css
server/sonar-web/src/main/js/components/nav/NavBarTabs.js [deleted file]
server/sonar-web/src/main/js/components/nav/NavBarTabs.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/shared/pending-icon.js [deleted file]
server/sonar-web/src/main/js/components/workspace/views/viewer-view.js
server/sonar-web/src/main/js/helpers/branches.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/urls.ts
server/sonar-web/src/main/js/store/rootActions.js
server/sonar-web/src/main/less/components/dropdowns.less
server/sonar-web/src/main/less/components/menu.less

diff --git a/server/sonar-web/src/main/js/api/branches.ts b/server/sonar-web/src/main/js/api/branches.ts
new file mode 100644 (file)
index 0000000..5c59738
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { getJSON } from '../helpers/request';
+import throwGlobalError from '../app/utils/throwGlobalError';
+
+export function getBranches(project: string): Promise<any> {
+  return getJSON('/api/project_branches/list', { project }).then(r => r.branches, throwGlobalError);
+}
+
+export function getBranch(project: string, branch: string): Promise<any> {
+  return getJSON('/api/project_branches/show', { component: project, branch }).then(
+    r => r.branch,
+    throwGlobalError
+  );
+}
index 50faefec07d02b1f9bfd8cb65b6a282b60a659d7..fd46c34d55933aa939722c7ccd27f59d08f06def 100644 (file)
@@ -113,8 +113,12 @@ export function getComponentLeaves(
   return getComponentTree('leaves', componentKey, metrics, additional);
 }
 
-export function getComponent(componentKey: string, metrics: string[] = []): Promise<any> {
-  const data = { componentKey, metricKeys: metrics.join(',') };
+export function getComponent(
+  componentKey: string,
+  metrics: string[] = [],
+  branch?: string
+): Promise<any> {
+  const data = { branch, componentKey, metricKeys: metrics.join(',') };
   return getJSON('/api/measures/component', data).then(r => r.component);
 }
 
@@ -122,23 +126,23 @@ export function getTree(component: string, options: RequestData = {}): Promise<a
   return getJSON('/api/components/tree', { ...options, component });
 }
 
-export function getComponentShow(component: string): Promise<any> {
-  return getJSON('/api/components/show', { component });
+export function getComponentShow(component: string, branch?: string): Promise<any> {
+  return getJSON('/api/components/show', { component, branch });
 }
 
 export function getParents(component: string): Promise<any> {
   return getComponentShow(component).then(r => r.ancestors);
 }
 
-export function getBreadcrumbs(component: string): Promise<any> {
-  return getComponentShow(component).then(r => {
+export function getBreadcrumbs(component: string, branch?: string): Promise<any> {
+  return getComponentShow(component, branch).then(r => {
     const reversedAncestors = [...r.ancestors].reverse();
     return [...reversedAncestors, r.component];
   });
 }
 
-export function getComponentData(component: string): Promise<any> {
-  return getComponentShow(component).then(r => r.component);
+export function getComponentData(component: string, branch?: string): Promise<any> {
+  return getComponentShow(component, branch).then(r => r.component);
 }
 
 export function getMyProjects(data: RequestData): Promise<any> {
@@ -219,12 +223,17 @@ export function getSuggestions(
   return getJSON('/api/components/suggestions', data);
 }
 
-export function getComponentForSourceViewer(component: string): Promise<any> {
-  return getJSON('/api/components/app', { component });
+export function getComponentForSourceViewer(component: string, branch?: string): Promise<any> {
+  return getJSON('/api/components/app', { component, branch });
 }
 
-export function getSources(component: string, from?: number, to?: number): Promise<any> {
-  const data: RequestData = { key: component };
+export function getSources(
+  component: string,
+  from?: number,
+  to?: number,
+  branch?: string
+): Promise<any> {
+  const data: RequestData = { key: component, branch };
   if (from) {
     Object.assign(data, { from });
   }
index 3b2046df1c048aa462073b6d0a240832ba79e124..0e1983d052785e91573a39f0655c9900dc8105e2 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { getJSON } from '../helpers/request';
+import throwGlobalError from '../app/utils/throwGlobalError';
 
 export function getGlobalNavigation(): Promise<any> {
   return getJSON('/api/navigation/global');
 }
 
-export function getComponentNavigation(componentKey: string): Promise<any> {
-  return getJSON('/api/navigation/component', { componentKey });
+export function getComponentNavigation(componentKey: string, branch?: string): Promise<any> {
+  return getJSON('/api/navigation/component', { componentKey, branch }).catch(throwGlobalError);
 }
 
 export function getSettingsNavigation(): Promise<any> {
-  return getJSON('/api/navigation/settings');
+  return getJSON('/api/navigation/settings').catch(throwGlobalError);
 }
index 40b591a198daef6e1da0f33e67198b593fd9fb6d..fed3232e2c2424f5f0b9855354ada98680d09ba4 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
-import { connect } from 'react-redux';
-import { getComponent } from '../../store/rootReducer';
 import handleRequiredAuthorization from '../utils/handleRequiredAuthorization';
 
-class ProjectAdminContainer extends React.PureComponent {
+export default class ProjectAdminContainer extends React.PureComponent {
   /*::
   props: {
-    project: {
+    component: {
       configuration?: {
         showSettings: boolean
       }
@@ -42,7 +40,7 @@ class ProjectAdminContainer extends React.PureComponent {
   }
 
   isProjectAdmin() {
-    const { configuration } = this.props.project;
+    const { configuration } = this.props.component;
     return configuration != null && configuration.showSettings;
   }
 
@@ -57,12 +55,8 @@ class ProjectAdminContainer extends React.PureComponent {
       return null;
     }
 
-    return this.props.children;
+    return React.cloneElement(this.props.children, {
+      component: this.props.component
+    });
   }
 }
-
-const mapStateToProps = (state, ownProps) => ({
-  project: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(ProjectAdminContainer);
diff --git a/server/sonar-web/src/main/js/app/components/ProjectContainer.js b/server/sonar-web/src/main/js/app/components/ProjectContainer.js
deleted file mode 100644 (file)
index b40bd8f..0000000
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import { connect } from 'react-redux';
-import ComponentNav from './nav/component/ComponentNav';
-import { fetchProject } from '../../store/rootActions';
-import { getComponent } from '../../store/rootReducer';
-import { addGlobalErrorMessage } from '../../store/globalMessages/duck';
-import { receiveComponents } from '../../store/components/actions';
-import { parseError } from '../../apps/code/utils';
-import handleRequiredAuthorization from '../utils/handleRequiredAuthorization';
-
-class ProjectContainer extends React.PureComponent {
-  /*::
-  props: {
-    addGlobalErrorMessage: (message: string) => void,
-    children?: React.Element<*>,
-    location: {
-      query: { id: string }
-    },
-    project?: {
-      configuration: {},
-      name: string,
-      qualifier: string
-    },
-    fetchProject: string => Promise<*>,
-    receiveComponents: (Array<*>) => void
-  };
-  */
-
-  componentDidMount() {
-    this.fetchProject();
-  }
-
-  componentDidUpdate(prevProps) {
-    if (prevProps.location.query.id !== this.props.location.query.id) {
-      this.fetchProject();
-    }
-  }
-
-  fetchProject() {
-    this.props.fetchProject(this.props.location.query.id).catch(e => {
-      if (e.response && e.response.status === 403) {
-        handleRequiredAuthorization();
-      } else {
-        parseError(e).then(message => this.props.addGlobalErrorMessage(message));
-      }
-    });
-  }
-
-  handleProjectChange = (changes /*: {} */) => {
-    this.props.receiveComponents([{ ...this.props.project, ...changes }]);
-  };
-
-  render() {
-    const { project } = this.props;
-
-    // check `breadcrumbs` to be sure that /api/navigation/component has been already called
-    if (!project || project.breadcrumbs == null) {
-      return null;
-    }
-
-    const isFile = ['FIL', 'UTS'].includes(project.qualifier);
-    const configuration = project.configuration || {};
-
-    return (
-      <div>
-        {!isFile &&
-          <ComponentNav component={project} conf={configuration} location={this.props.location} />}
-        {/* $FlowFixMe */}
-        {React.cloneElement(this.props.children, {
-          component: project,
-          onComponentChange: this.handleProjectChange
-        })}
-      </div>
-    );
-  }
-}
-
-const mapStateToProps = (state, ownProps) => ({
-  project: getComponent(state, ownProps.location.query.id)
-});
-
-const mapDispatchToProps = { addGlobalErrorMessage, fetchProject, receiveComponents };
-
-export default connect(mapStateToProps, mapDispatchToProps)(ProjectContainer);
diff --git a/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx b/server/sonar-web/src/main/js/app/components/ProjectContainer.tsx
new file mode 100644 (file)
index 0000000..ef19629
--- /dev/null
@@ -0,0 +1,143 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 ComponentNav from './nav/component/ComponentNav';
+import { Branch, Component } from '../types';
+import handleRequiredAuthorization from '../utils/handleRequiredAuthorization';
+import { getBranch } from '../../api/branches';
+import { getComponentData } from '../../api/components';
+import { getComponentNavigation } from '../../api/nav';
+import { MAIN_BRANCH } from '../../helpers/branches';
+
+interface Props {
+  children: any;
+  location: {
+    query: { branch?: string; id: string };
+  };
+}
+
+interface State {
+  branch: Branch | null;
+  loading: boolean;
+  component: Component | null;
+}
+
+export default class ProjectContainer extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = { branch: null, loading: true, component: null };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchProject();
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    // if the current branch has been changed, reset `branch` in state
+    // it prevents unwanted redirect in `overview/App#componentDidMount`
+    if (nextProps.location.query.branch !== this.props.location.query.branch) {
+      this.setState({ branch: null });
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (
+      prevProps.location.query.id !== this.props.location.query.id ||
+      prevProps.location.query.branch !== this.props.location.query.branch
+    ) {
+      this.fetchProject();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  addQualifier = (component: Component) => ({
+    ...component,
+    qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier
+  });
+
+  fetchProject() {
+    const { branch, id } = this.props.location.query;
+    this.setState({ loading: true });
+    Promise.all([
+      getComponentNavigation(id),
+      getComponentData(id, branch),
+      branch && getBranch(id, branch)
+    ]).then(
+      ([nav, data, branch]) => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            branch: branch || MAIN_BRANCH,
+            component: this.addQualifier({ ...nav, ...data })
+          });
+        }
+      },
+      error => {
+        if (this.mounted) {
+          if (error.response && error.response.status === 403) {
+            handleRequiredAuthorization();
+          } else {
+            this.setState({ loading: false });
+          }
+        }
+      }
+    );
+  }
+
+  handleProjectChange = (changes: {}) => {
+    if (this.mounted) {
+      this.setState(state => ({ component: { ...state.component, ...changes } }));
+    }
+  };
+
+  render() {
+    const { branch, component } = this.state;
+
+    if (!component || !branch) {
+      return null;
+    }
+
+    const isFile = ['FIL', 'UTS'].includes(component.qualifier);
+    const configuration = component.configuration || {};
+
+    return (
+      <div>
+        {!isFile &&
+          <ComponentNav
+            branch={branch}
+            component={component}
+            conf={configuration}
+            location={this.props.location}
+          />}
+        {React.cloneElement(this.props.children, {
+          branch,
+          component: component,
+          onComponentChange: this.handleProjectChange
+        })}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/ProjectContainer-test.tsx
new file mode 100644 (file)
index 0000000..5400c85
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ProjectContainer from '../ProjectContainer';
+
+it('changes component', () => {
+  const Inner = () => <div />;
+
+  const wrapper = shallow(
+    <ProjectContainer location={{ query: { id: 'foo' } }}>
+      <Inner />
+    </ProjectContainer>
+  );
+  (wrapper.instance() as ProjectContainer).mounted = true;
+  wrapper.setState({
+    branch: { isMain: true },
+    component: { qualifier: 'TRK', visibility: 'public' },
+    loading: false
+  });
+
+  (wrapper.find(Inner).prop('onComponentChange') as Function)({ visibility: 'private' });
+  expect(wrapper.state().component).toEqual({ qualifier: 'TRK', visibility: 'private' });
+});
diff --git a/server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.js b/server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.js
deleted file mode 100644 (file)
index 330b818..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import { Link } from 'react-router';
-
-export default class ExtensionNotFound extends React.PureComponent {
-  componentDidMount() {
-    const html = document.querySelector('html');
-    if (html) {
-      html.classList.add('dashboard-page');
-    }
-  }
-
-  componentWillUnmount() {
-    const html = document.querySelector('html');
-    if (html) {
-      html.classList.remove('dashboard-page');
-    }
-  }
-
-  render() {
-    return (
-      <div id="bd" className="page-wrapper-simple">
-        <div id="nonav" className="page-simple">
-          <h2 className="big-spacer-bottom">The page you were looking for does not exist.</h2>
-          <p className="spacer-bottom">
-            You may have mistyped the address or the page may have moved.
-          </p>
-          <p>
-            <Link to="/">Go back to the homepage</Link>
-          </p>
-        </div>
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.tsx b/server/sonar-web/src/main/js/app/components/extensions/ExtensionNotFound.tsx
new file mode 100644 (file)
index 0000000..154b674
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { Link } from 'react-router';
+
+export default class ExtensionNotFound extends React.PureComponent {
+  componentDidMount() {
+    const html = document.querySelector('html');
+    if (html) {
+      html.classList.add('dashboard-page');
+    }
+  }
+
+  componentWillUnmount() {
+    const html = document.querySelector('html');
+    if (html) {
+      html.classList.remove('dashboard-page');
+    }
+  }
+
+  render() {
+    return (
+      <div id="bd" className="page-wrapper-simple">
+        <div id="nonav" className="page-simple">
+          <h2 className="big-spacer-bottom">The page you were looking for does not exist.</h2>
+          <p className="spacer-bottom">
+            You may have mistyped the address or the page may have moved.
+          </p>
+          <p>
+            <Link to="/">Go back to the homepage</Link>
+          </p>
+        </div>
+      </div>
+    );
+  }
+}
index 3a432553dd83a0cad8df187a076066badf1f635d..a72607ce2d0db4e10fe7565fc7454506dfbaa159 100644 (file)
@@ -22,7 +22,6 @@ import React from 'react';
 import { connect } from 'react-redux';
 import Extension from './Extension';
 import ExtensionNotFound from './ExtensionNotFound';
-import { getComponent } from '../../../store/rootReducer';
 import { addGlobalErrorMessage } from '../../../store/globalMessages/duck';
 
 /*::
@@ -51,10 +50,6 @@ function ProjectAdminPageExtension(props /*: Props */) {
     : <ExtensionNotFound />;
 }
 
-const mapStateToProps = (state, ownProps /*: Props */) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
 const mapDispatchToProps = { onFail: addGlobalErrorMessage };
 
-export default connect(mapStateToProps, mapDispatchToProps)(ProjectAdminPageExtension);
+export default connect(null, mapDispatchToProps)(ProjectAdminPageExtension);
diff --git a/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.js b/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.js
deleted file mode 100644 (file)
index dd216b5..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import { connect } from 'react-redux';
-import Extension from './Extension';
-import ExtensionNotFound from './ExtensionNotFound';
-import { getComponent } from '../../../store/rootReducer';
-import { addGlobalErrorMessage } from '../../../store/globalMessages/duck';
-
-/*::
-type Props = {
-  component: {
-    extensions: Array<{ key: string }>
-  },
-  location: { query: { id: string } },
-  params: {
-    extensionKey: string,
-    pluginKey: string
-  }
-};
-*/
-
-function ProjectPageExtension(props /*: Props */) {
-  const { extensionKey, pluginKey } = props.params;
-  const { component } = props;
-  const extension = component.extensions.find(p => p.key === `${pluginKey}/${extensionKey}`);
-  return extension
-    ? <Extension extension={extension} options={{ component }} />
-    : <ExtensionNotFound />;
-}
-
-const mapStateToProps = (state, ownProps /*: Props */) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
-const mapDispatchToProps = { onFail: addGlobalErrorMessage };
-
-export default connect(mapStateToProps, mapDispatchToProps)(ProjectPageExtension);
diff --git a/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx b/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx
new file mode 100644 (file)
index 0000000..c06716a
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Extension from './Extension';
+import ExtensionNotFound from './ExtensionNotFound';
+import { Component } from '../../types';
+
+interface Props {
+  component: Component;
+  location: { query: { id: string } };
+  params: {
+    extensionKey: string;
+    pluginKey: string;
+  };
+}
+
+export default function ProjectPageExtension(props: Props) {
+  const { extensionKey, pluginKey } = props.params;
+  const { component } = props;
+  const extension =
+    component.extensions &&
+    component.extensions.find(p => p.key === `${pluginKey}/${extensionKey}`);
+  return extension
+    ? <Extension extension={extension} options={{ component }} />
+    : <ExtensionNotFound />;
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css b/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.css
new file mode 100644 (file)
index 0000000..b52fc69
--- /dev/null
@@ -0,0 +1,18 @@
+.branch-status {
+}
+
+.branch-status-indicator {
+  display: block;
+  width: 8px;
+  height: 8px;
+  border-radius: 8px;
+  margin: 4px 0;
+}
+
+.branch-status-indicator.is-failed {
+  background-color: #d4333f;
+}
+
+.branch-status-indicator.is-passed {
+  background-color: #00aa00;
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx b/server/sonar-web/src/main/js/app/components/nav/component/BranchStatus.tsx
new file mode 100644 (file)
index 0000000..bac5d7e
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 classNames from 'classnames';
+import { Branch } from '../../../types';
+import BugIcon from '../../../../components/icons-components/BugIcon';
+import CodeSmellIcon from '../../../../components/icons-components/CodeSmellIcon';
+import VulnerabilityIcon from '../../../../components/icons-components/VulnerabilityIcon';
+import { isShortLivingBranch } from '../../../../helpers/branches';
+import './BranchStatus.css';
+
+interface Props {
+  branch: Branch;
+  concise?: boolean;
+}
+
+export default function BranchStatus({ branch, concise = false }: Props) {
+  // TODO handle long-living branches
+  if (!isShortLivingBranch(branch)) {
+    return null;
+  }
+
+  const totalIssues = branch.status.bugs + branch.status.vulnerabilities + branch.status.codeSmells;
+
+  return (
+    <ul className="list-inline branch-status">
+      <li>
+        <i
+          className={classNames('branch-status-indicator', {
+            'is-failed': totalIssues > 0,
+            'is-passed': totalIssues === 0
+          })}
+        />
+      </li>
+      {concise &&
+        <li>
+          {totalIssues}
+        </li>}
+      {!concise &&
+        <li>
+          {branch.status.bugs}
+          <BugIcon className="little-spacer-left" />
+        </li>}
+      {!concise &&
+        <li>
+          {branch.status.vulnerabilities}
+          <VulnerabilityIcon className="little-spacer-left" />
+        </li>}
+      {!concise &&
+        <li>
+          {branch.status.codeSmells}
+          <CodeSmellIcon className="little-spacer-left" />
+        </li>}
+    </ul>
+  );
+}
index 22ec0d36ea17fcbdae53b1241839c581eb49e5a6..e29574c39e489222a2f6361798e7c5c1aee8f610 100644 (file)
@@ -9,3 +9,20 @@
   padding-top: 5px;
   box-sizing: border-box;
 }
+
+.navbar-context-branches {
+  float: left;
+  padding: 8px 0 6px;
+  margin-left: 16px;
+  line-height: 16px;
+}
+
+.navbar-context-meta-branch {
+  margin-top: 20px;
+  line-height: 16px;
+}
+
+.navbar-context-meta-branch-menu-item {
+  display: flex !important;
+  justify-content: space-between;
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js
deleted file mode 100644 (file)
index 0e21abc..0000000
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-import ComponentNavFavorite from './ComponentNavFavorite';
-import ComponentNavBreadcrumbs from './ComponentNavBreadcrumbs';
-import ComponentNavMeta from './ComponentNavMeta';
-import ComponentNavMenu from './ComponentNavMenu';
-import RecentHistory from '../../RecentHistory';
-import ContextNavBar from '../../../../components/nav/ContextNavBar';
-import { getTasksForComponent } from '../../../../api/ce';
-import { STATUSES } from '../../../../apps/background-tasks/constants';
-import './ComponentNav.css';
-
-export default class ComponentNav extends React.PureComponent {
-  componentDidMount() {
-    this.mounted = true;
-
-    this.loadStatus();
-    this.populateRecentHistory();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  loadStatus = () => {
-    getTasksForComponent(this.props.component.key).then(r => {
-      if (this.mounted) {
-        this.setState({
-          isPending: r.queue.some(task => task.status === STATUSES.PENDING),
-          isInProgress: r.queue.some(task => task.status === STATUSES.IN_PROGRESS),
-          isFailed: r.current && r.current.status === STATUSES.FAILED,
-          incremental: r.current && r.current.incremental
-        });
-      }
-    });
-  };
-
-  populateRecentHistory = () => {
-    const { breadcrumbs } = this.props.component;
-    const { qualifier } = breadcrumbs[breadcrumbs.length - 1];
-    if (['TRK', 'VW', 'APP', 'DEV'].indexOf(qualifier) !== -1) {
-      RecentHistory.add(
-        this.props.component.key,
-        this.props.component.name,
-        qualifier.toLowerCase(),
-        this.props.component.organization
-      );
-    }
-  };
-
-  render() {
-    return (
-      <ContextNavBar id="context-navigation" height={65}>
-        <ComponentNavFavorite
-          component={this.props.component.key}
-          favorite={this.props.component.isFavorite}
-        />
-
-        <ComponentNavBreadcrumbs
-          component={this.props.component}
-          breadcrumbs={this.props.component.breadcrumbs}
-        />
-
-        <ComponentNavMeta
-          {...this.props}
-          {...this.state}
-          version={this.props.component.version}
-          analysisDate={this.props.component.analysisDate}
-        />
-
-        <ComponentNavMenu
-          component={this.props.component}
-          conf={this.props.conf}
-          location={this.props.location}
-        />
-      </ContextNavBar>
-    );
-  }
-}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
new file mode 100644 (file)
index 0000000..9729aea
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 ComponentNavFavorite from './ComponentNavFavorite';
+import ComponentNavBreadcrumbs from './ComponentNavBreadcrumbs';
+import ComponentNavMeta from './ComponentNavMeta';
+import ComponentNavMenu from './ComponentNavMenu';
+import ComponentNavBranch from './ComponentNavBranch';
+import RecentHistory from '../../RecentHistory';
+import { Branch, Component, ComponentConfiguration } from '../../../types';
+import ContextNavBar from '../../../../components/nav/ContextNavBar';
+import { getTasksForComponent } from '../../../../api/ce';
+import { STATUSES } from '../../../../apps/background-tasks/constants';
+import './ComponentNav.css';
+
+interface Props {
+  branch: Branch;
+  component: Component;
+  conf: ComponentConfiguration;
+  location: {};
+}
+
+interface State {
+  incremental?: boolean;
+  isFailed?: boolean;
+  isInProgress?: boolean;
+  isPending?: boolean;
+}
+
+export default class ComponentNav extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  state: State = {};
+
+  componentDidMount() {
+    this.mounted = true;
+    this.loadStatus();
+    this.populateRecentHistory();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  loadStatus = () => {
+    getTasksForComponent(this.props.component.key).then((r: any) => {
+      if (this.mounted) {
+        this.setState({
+          isPending: r.queue.some((task: any) => task.status === STATUSES.PENDING),
+          isInProgress: r.queue.some((task: any) => task.status === STATUSES.IN_PROGRESS),
+          isFailed: r.current && r.current.status === STATUSES.FAILED,
+          incremental: r.current && r.current.incremental
+        });
+      }
+    });
+  };
+
+  populateRecentHistory = () => {
+    const { breadcrumbs } = this.props.component;
+    const { qualifier } = breadcrumbs[breadcrumbs.length - 1];
+    if (['TRK', 'VW', 'APP', 'DEV'].indexOf(qualifier) !== -1) {
+      RecentHistory.add(
+        this.props.component.key,
+        this.props.component.name,
+        qualifier.toLowerCase(),
+        this.props.component.organization
+      );
+    }
+  };
+
+  render() {
+    return (
+      <ContextNavBar id="context-navigation" height={65}>
+        <ComponentNavFavorite
+          component={this.props.component.key}
+          favorite={this.props.component.isFavorite}
+        />
+
+        <ComponentNavBreadcrumbs
+          component={this.props.component}
+          breadcrumbs={this.props.component.breadcrumbs}
+        />
+
+        <ComponentNavBranch branch={this.props.branch} project={this.props.component} />
+
+        <ComponentNavMeta
+          branch={this.props.branch}
+          component={this.props.component}
+          conf={this.props.conf}
+          incremental={this.state.incremental}
+        />
+
+        <ComponentNavMenu
+          branch={this.props.branch}
+          component={this.props.component}
+          conf={this.props.conf}
+        />
+      </ContextNavBar>
+    );
+  }
+}
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
new file mode 100644 (file)
index 0000000..1ac3fa2
--- /dev/null
@@ -0,0 +1,84 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 classNames from 'classnames';
+import ComponentNavBranchesMenu from './ComponentNavBranchesMenu';
+import { Branch, Component } from '../../../types';
+import BranchIcon from '../../../../components/icons-components/BranchIcon';
+import { getBranchDisplayName } from '../../../../helpers/branches';
+
+interface Props {
+  branch: Branch;
+  project: Component;
+}
+
+interface State {
+  open: boolean;
+}
+
+export default class ComponentNavBranch extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { open: false };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.project !== this.props.project || nextProps.branch !== this.props.branch) {
+      this.setState({ open: false });
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleClick = (event: React.SyntheticEvent<HTMLElement>) => {
+    event.preventDefault();
+    event.stopPropagation();
+    event.currentTarget.blur();
+    this.setState({ open: true });
+  };
+
+  closeDropdown = () => {
+    if (this.mounted) {
+      this.setState({ open: false });
+    }
+  };
+
+  render() {
+    return (
+      <div className={classNames('navbar-context-branches', 'dropdown', { open: this.state.open })}>
+        <a className="link-base-color link-no-underline" href="#" onClick={this.handleClick}>
+          <BranchIcon className="little-spacer-right" />
+          {getBranchDisplayName(this.props.branch)}
+          <i className="icon-dropdown little-spacer-left" />
+        </a>
+        {this.state.open &&
+          <ComponentNavBranchesMenu
+            branch={this.props.branch}
+            onClose={this.closeDropdown}
+            project={this.props.project}
+          />}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx
new file mode 100644 (file)
index 0000000..6efe8e2
--- /dev/null
@@ -0,0 +1,222 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 PropTypes from 'prop-types';
+import { sortBy } from 'lodash';
+import ComponentNavBranchesMenuItem from './ComponentNavBranchesMenuItem';
+import { Branch, Component } from '../../../types';
+import { getBranches } from '../../../../api/branches';
+import { isShortLivingBranch, getBranchDisplayName } from '../../../../helpers/branches';
+import { translate } from '../../../../helpers/l10n';
+import { getProjectBranchUrl } from '../../../../helpers/urls';
+
+interface Props {
+  branch: Branch;
+  onClose: () => void;
+  project: Component;
+}
+
+interface State {
+  branches: Branch[];
+  loading: boolean;
+  query: string;
+  selected: string | null;
+}
+
+export default class ComponentNavBranchesMenu extends React.PureComponent<Props, State> {
+  private mounted: boolean;
+  private node: HTMLElement | null;
+
+  static contextTypes = {
+    router: PropTypes.object
+  };
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      branches: [],
+      loading: true,
+      query: '',
+      selected: null
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchBranches();
+    window.addEventListener('click', this.handleClickOutside);
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    window.removeEventListener('click', this.handleClickOutside);
+  }
+
+  fetchBranches = () => {
+    this.setState({ loading: true });
+    getBranches(this.props.project.key).then(
+      (branches: Branch[]) => {
+        if (this.mounted) {
+          this.setState({ branches: this.sortBranches(branches), loading: false });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  };
+
+  sortBranches = (branches: Branch[]): Branch[] =>
+    sortBy(
+      branches,
+      branch => !branch.isMain, // main branch first
+      branch => !isShortLivingBranch(branch), // then short-living branches
+      branch => getBranchDisplayName(branch) // then by name
+    );
+
+  getFilteredBranches = () =>
+    this.state.branches.filter(branch =>
+      getBranchDisplayName(branch).toLowerCase().includes(this.state.query.toLowerCase())
+    );
+
+  handleClickOutside = (event: Event) => {
+    if (!this.node || !this.node.contains(event.target as HTMLElement)) {
+      this.props.onClose();
+    }
+  };
+
+  handleSearchChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
+    this.setState({ query: event.currentTarget.value, selected: null });
+
+  handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+    switch (event.keyCode) {
+      case 13:
+        event.preventDefault();
+        this.openSelected();
+        return;
+      case 27:
+        event.preventDefault();
+        this.props.onClose();
+        return;
+      case 38:
+        event.preventDefault();
+        this.selectPrevious();
+        return;
+      case 40:
+        event.preventDefault();
+        this.selectNext();
+        return;
+    }
+  };
+
+  openSelected = () => {
+    const selected = this.getSelected();
+    const branch = this.getFilteredBranches().find(
+      branch => getBranchDisplayName(branch) === selected
+    );
+    if (branch) {
+      this.context.router.push(this.getProjectBranchUrl(branch));
+    }
+  };
+
+  selectPrevious = () => {
+    const selected = this.getSelected();
+    const branches = this.getFilteredBranches();
+    const index = branches.findIndex(branch => getBranchDisplayName(branch) === selected);
+    if (index > 0) {
+      this.setState({ selected: getBranchDisplayName(branches[index - 1]) });
+    }
+  };
+
+  selectNext = () => {
+    const selected = this.getSelected();
+    const branches = this.getFilteredBranches();
+    const index = branches.findIndex(branch => getBranchDisplayName(branch) === selected);
+    if (index >= 0 && index < branches.length - 1) {
+      this.setState({ selected: getBranchDisplayName(branches[index + 1]) });
+    }
+  };
+
+  handleSelect = (branch: Branch) => {
+    this.setState({ selected: getBranchDisplayName(branch) });
+  };
+
+  getSelected = () => {
+    const branches = this.getFilteredBranches();
+    return this.state.selected || (branches.length > 0 && getBranchDisplayName(branches[0]));
+  };
+
+  getProjectBranchUrl = (branch: Branch) => getProjectBranchUrl(this.props.project.key, branch);
+
+  isSelected = (branch: Branch) => getBranchDisplayName(branch) === this.getSelected();
+
+  renderSearch = () =>
+    <div className="search-box menu-search">
+      <button className="search-box-submit button-clean">
+        <i className="icon-search-new" />
+      </button>
+      <input
+        autoFocus={true}
+        className="search-box-input"
+        onChange={this.handleSearchChange}
+        onKeyDown={this.handleKeyDown}
+        placeholder={translate('search_verb')}
+        type="search"
+        value={this.state.query}
+      />
+    </div>;
+
+  renderBranchesList = () => {
+    const branches = this.getFilteredBranches();
+
+    const selected = this.getSelected();
+
+    return branches.length > 0
+      ? <ul className="menu">
+          {branches.map(branch =>
+            <ComponentNavBranchesMenuItem
+              branch={branch}
+              component={this.props.project}
+              key={getBranchDisplayName(branch)}
+              onSelect={this.handleSelect}
+              selected={getBranchDisplayName(branch) === selected}
+            />
+          )}
+        </ul>
+      : <div className="menu-message note">
+          {translate('no_results')}
+        </div>;
+  };
+
+  render() {
+    return (
+      <div className="dropdown-menu dropdown-menu-shadow" ref={node => (this.node = node)}>
+        {this.state.loading
+          ? <i className="spinner" />
+          : <div>
+              {this.renderSearch()}
+              {this.renderBranchesList()}
+            </div>}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenuItem.tsx
new file mode 100644 (file)
index 0000000..4482156
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { Link } from 'react-router';
+import * as classNames from 'classnames';
+import BranchStatus from './BranchStatus';
+import { Branch, Component } from '../../../types';
+import BranchIcon from '../../../../components/icons-components/BranchIcon';
+import { isShortLivingBranch, getBranchDisplayName } from '../../../../helpers/branches';
+import { getProjectBranchUrl } from '../../../../helpers/urls';
+
+interface Props {
+  branch: Branch;
+  component: Component;
+  onSelect: (branch: Branch) => void;
+  selected: boolean;
+}
+
+export default function ComponentNavBranchesMenuItem({ branch, ...props }: Props) {
+  const displayName = getBranchDisplayName(branch);
+
+  const handleMouseEnter = () => {
+    props.onSelect(branch);
+  };
+
+  return (
+    <li key={displayName} onMouseEnter={handleMouseEnter}>
+      <Link
+        className={classNames('navbar-context-meta-branch-menu-item', {
+          active: props.selected
+        })}
+        to={getProjectBranchUrl(props.component.key, branch)}>
+        <div>
+          <BranchIcon
+            className={classNames('little-spacer-right', {
+              'big-spacer-left': isShortLivingBranch(branch)
+            })}
+          />
+          {displayName}
+        </div>
+        <div className="big-spacer-left note">
+          <BranchStatus branch={branch} concise={true} />
+        </div>
+      </Link>
+    </li>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.js
deleted file mode 100644 (file)
index dfbd789..0000000
+++ /dev/null
@@ -1,370 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-import PropTypes from 'prop-types';
-import { Link } from 'react-router';
-import classNames from 'classnames';
-import NavBarTabs from '../../../../components/nav/NavBarTabs';
-import { translate } from '../../../../helpers/l10n';
-
-const SETTINGS_URLS = [
-  '/project/admin',
-  '/project/settings',
-  '/project/quality_profiles',
-  '/project/quality_gate',
-  '/custom_measures',
-  '/project/links',
-  '/project_roles',
-  '/project/history',
-  'background_tasks',
-  '/project/key',
-  '/project/deletion'
-];
-
-export default class ComponentNavMenu extends React.PureComponent {
-  static propTypes = {
-    component: PropTypes.object.isRequired,
-    conf: PropTypes.object.isRequired
-  };
-
-  isProject() {
-    return this.props.component.qualifier === 'TRK';
-  }
-
-  isDeveloper() {
-    return this.props.component.qualifier === 'DEV';
-  }
-
-  isView() {
-    const { qualifier } = this.props.component;
-    return qualifier === 'VW' || qualifier === 'SVW';
-  }
-
-  isApplication() {
-    return this.props.component.qualifier === 'APP';
-  }
-
-  renderDashboardLink() {
-    const pathname = this.isView() ? '/portfolio' : '/dashboard';
-    return (
-      <li>
-        <Link to={{ pathname, query: { id: this.props.component.key } }} activeClassName="active">
-          {translate('overview.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderCodeLink() {
-    if (this.isDeveloper()) {
-      return null;
-    }
-
-    return (
-      <li>
-        <Link
-          to={{ pathname: '/code', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {this.isView() || this.isApplication()
-            ? translate('view_projects.page')
-            : translate('code.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderActivityLink() {
-    if (!this.isProject() && !this.isApplication()) {
-      return null;
-    }
-
-    return (
-      <li>
-        <Link
-          to={{ pathname: '/project/activity', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('project_activity.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderIssuesLink() {
-    return (
-      <li>
-        <Link
-          to={{
-            pathname: '/project/issues',
-            query: { id: this.props.component.key, resolved: 'false' }
-          }}
-          activeClassName="active">
-          {translate('issues.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderComponentMeasuresLink() {
-    return (
-      <li>
-        <Link
-          to={{ pathname: '/component_measures', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('layout.measures')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderAdministration() {
-    const adminLinks = this.renderAdministrationLinks();
-    if (!adminLinks.some(link => link != null)) {
-      return null;
-    }
-
-    const isSettingsActive = SETTINGS_URLS.some(url => window.location.href.indexOf(url) !== -1);
-    return (
-      <li className="dropdown">
-        <a
-          className={classNames('dropdown-toggle', 'is-admin', { active: isSettingsActive })}
-          id="component-navigation-admin"
-          data-toggle="dropdown"
-          href="#">
-          {translate('layout.settings')}&nbsp;
-          <i className="icon-dropdown" />
-        </a>
-        <ul className="dropdown-menu">
-          {adminLinks}
-        </ul>
-      </li>
-    );
-  }
-
-  renderAdministrationLinks() {
-    return [
-      this.renderSettingsLink(),
-      this.renderProfilesLink(),
-      this.renderQualityGateLink(),
-      this.renderCustomMeasuresLink(),
-      this.renderLinksLink(),
-      this.renderPermissionsLink(),
-      this.renderBackgroundTasksLink(),
-      this.renderUpdateKeyLink(),
-      ...this.renderAdminExtensions(),
-      this.renderDeletionLink()
-    ];
-  }
-
-  renderSettingsLink() {
-    if (!this.props.conf.showSettings || this.isApplication() || this.isView()) {
-      return null;
-    }
-    return (
-      <li key="settings">
-        <Link
-          to={{ pathname: '/project/settings', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('project_settings.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderProfilesLink() {
-    if (!this.props.conf.showQualityProfiles) {
-      return null;
-    }
-    return (
-      <li key="profiles">
-        <Link
-          to={{ pathname: '/project/quality_profiles', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('project_quality_profiles.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderQualityGateLink() {
-    if (!this.props.conf.showQualityGates) {
-      return null;
-    }
-    return (
-      <li key="quality_gate">
-        <Link
-          to={{ pathname: '/project/quality_gate', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('project_quality_gate.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderCustomMeasuresLink() {
-    if (!this.props.conf.showManualMeasures) {
-      return null;
-    }
-    return (
-      <li key="custom_measures">
-        <Link
-          to={{ pathname: '/custom_measures', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('custom_measures.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderLinksLink() {
-    if (!this.props.conf.showLinks) {
-      return null;
-    }
-    return (
-      <li key="links">
-        <Link
-          to={{ pathname: '/project/links', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('project_links.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderPermissionsLink() {
-    if (!this.props.conf.showPermissions) {
-      return null;
-    }
-    return (
-      <li key="permissions">
-        <Link
-          to={{ pathname: '/project_roles', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('permissions.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderBackgroundTasksLink() {
-    if (!this.props.conf.showBackgroundTasks) {
-      return null;
-    }
-    return (
-      <li key="background_tasks">
-        <Link
-          to={{ pathname: '/project/background_tasks', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('background_tasks.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderUpdateKeyLink() {
-    if (!this.props.conf.showUpdateKey) {
-      return null;
-    }
-    return (
-      <li key="update_key">
-        <Link
-          to={{ pathname: '/project/key', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('update_key.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderDeletionLink() {
-    const { qualifier } = this.props.component;
-
-    if (!this.props.conf.showSettings) {
-      return null;
-    }
-
-    if (qualifier !== 'TRK' && qualifier !== 'VW' && qualifier !== 'APP') {
-      return null;
-    }
-
-    return (
-      <li key="project_delete">
-        <Link
-          to={{ pathname: '/project/deletion', query: { id: this.props.component.key } }}
-          activeClassName="active">
-          {translate('deletion.page')}
-        </Link>
-      </li>
-    );
-  }
-
-  renderExtension = ({ key, name }, isAdmin) => {
-    const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`;
-    return (
-      <li key={key}>
-        <Link to={{ pathname, query: { id: this.props.component.key } }} activeClassName="active">
-          {name}
-        </Link>
-      </li>
-    );
-  };
-
-  renderAdminExtensions() {
-    const extensions = this.props.conf.extensions || [];
-    return extensions.map(e => this.renderExtension(e, true));
-  }
-
-  renderExtensions() {
-    const extensions = this.props.component.extensions || [];
-    const withoutGovernance = extensions.filter(ext => ext.name !== 'Governance');
-    if (!withoutGovernance.length) {
-      return null;
-    }
-
-    return (
-      <li className="dropdown">
-        <a
-          className="dropdown-toggle"
-          id="component-navigation-more"
-          data-toggle="dropdown"
-          href="#">
-          {translate('more')}&nbsp;
-          <i className="icon-dropdown" />
-        </a>
-        <ul className="dropdown-menu">
-          {withoutGovernance.map(e => this.renderExtension(e, false))}
-        </ul>
-      </li>
-    );
-  }
-
-  render() {
-    return (
-      <NavBarTabs>
-        {this.renderDashboardLink()}
-        {this.renderIssuesLink()}
-        {this.renderComponentMeasuresLink()}
-        {this.renderCodeLink()}
-        {this.renderActivityLink()}
-        {this.renderAdministration()}
-        {this.renderExtensions()}
-      </NavBarTabs>
-    );
-  }
-}
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
new file mode 100644 (file)
index 0000000..15c6087
--- /dev/null
@@ -0,0 +1,395 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 { Link } from 'react-router';
+import * as classNames from 'classnames';
+import { Branch, Component, ComponentExtension, ComponentConfiguration } from '../../../types';
+import NavBarTabs from '../../../../components/nav/NavBarTabs';
+import { isShortLivingBranch } from '../../../../helpers/branches';
+import { translate } from '../../../../helpers/l10n';
+
+const SETTINGS_URLS = [
+  '/project/admin',
+  '/project/settings',
+  '/project/quality_profiles',
+  '/project/quality_gate',
+  '/custom_measures',
+  '/project/links',
+  '/project_roles',
+  '/project/history',
+  'background_tasks',
+  '/project/key',
+  '/project/deletion'
+];
+
+interface Props {
+  branch: Branch;
+  component: Component;
+  conf: ComponentConfiguration;
+}
+
+export default class ComponentNavMenu extends React.PureComponent<Props> {
+  isProject() {
+    return this.props.component.qualifier === 'TRK';
+  }
+
+  isDeveloper() {
+    return this.props.component.qualifier === 'DEV';
+  }
+
+  isView() {
+    const { qualifier } = this.props.component;
+    return qualifier === 'VW' || qualifier === 'SVW';
+  }
+
+  isApplication() {
+    return this.props.component.qualifier === 'APP';
+  }
+
+  renderDashboardLink() {
+    if (isShortLivingBranch(this.props.branch)) {
+      return null;
+    }
+
+    const pathname = this.isView() ? '/portfolio' : '/dashboard';
+    return (
+      <li>
+        <Link to={{ pathname, query: { id: this.props.component.key } }} activeClassName="active">
+          {translate('overview.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderCodeLink() {
+    if (this.isDeveloper()) {
+      return null;
+    }
+
+    return (
+      <li>
+        <Link
+          to={{
+            pathname: '/code',
+            query: { branch: this.props.branch.name, id: this.props.component.key }
+          }}
+          activeClassName="active">
+          {this.isView() || this.isApplication()
+            ? translate('view_projects.page')
+            : translate('code.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderActivityLink() {
+    if (!this.isProject() && !this.isApplication()) {
+      return null;
+    }
+
+    if (isShortLivingBranch(this.props.branch)) {
+      return null;
+    }
+
+    return (
+      <li>
+        <Link
+          to={{ pathname: '/project/activity', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('project_activity.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderIssuesLink() {
+    return (
+      <li>
+        <Link
+          to={{
+            pathname: '/project/issues',
+            query: {
+              branch: this.props.branch.name,
+              id: this.props.component.key,
+              resolved: 'false'
+            }
+          }}
+          activeClassName="active">
+          {translate('issues.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderComponentMeasuresLink() {
+    if (isShortLivingBranch(this.props.branch)) {
+      return null;
+    }
+
+    return (
+      <li>
+        <Link
+          to={{ pathname: '/component_measures', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('layout.measures')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderAdministration() {
+    if (isShortLivingBranch(this.props.branch)) {
+      return null;
+    }
+
+    const adminLinks = this.renderAdministrationLinks();
+    if (!adminLinks.some(link => link != null)) {
+      return null;
+    }
+
+    const isSettingsActive = SETTINGS_URLS.some(url => window.location.href.indexOf(url) !== -1);
+    return (
+      <li className="dropdown">
+        <a
+          className={classNames('dropdown-toggle', 'is-admin', { active: isSettingsActive })}
+          id="component-navigation-admin"
+          data-toggle="dropdown"
+          href="#">
+          {translate('layout.settings')}&nbsp;
+          <i className="icon-dropdown" />
+        </a>
+        <ul className="dropdown-menu">
+          {adminLinks}
+        </ul>
+      </li>
+    );
+  }
+
+  renderAdministrationLinks() {
+    return [
+      this.renderSettingsLink(),
+      this.renderProfilesLink(),
+      this.renderQualityGateLink(),
+      this.renderCustomMeasuresLink(),
+      this.renderLinksLink(),
+      this.renderPermissionsLink(),
+      this.renderBackgroundTasksLink(),
+      this.renderUpdateKeyLink(),
+      ...this.renderAdminExtensions(),
+      this.renderDeletionLink()
+    ];
+  }
+
+  renderSettingsLink() {
+    if (!this.props.conf.showSettings || this.isApplication() || this.isView()) {
+      return null;
+    }
+    return (
+      <li key="settings">
+        <Link
+          to={{ pathname: '/project/settings', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('project_settings.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderProfilesLink() {
+    if (!this.props.conf.showQualityProfiles) {
+      return null;
+    }
+    return (
+      <li key="profiles">
+        <Link
+          to={{ pathname: '/project/quality_profiles', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('project_quality_profiles.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderQualityGateLink() {
+    if (!this.props.conf.showQualityGates) {
+      return null;
+    }
+    return (
+      <li key="quality_gate">
+        <Link
+          to={{ pathname: '/project/quality_gate', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('project_quality_gate.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderCustomMeasuresLink() {
+    if (!this.props.conf.showManualMeasures) {
+      return null;
+    }
+    return (
+      <li key="custom_measures">
+        <Link
+          to={{ pathname: '/custom_measures', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('custom_measures.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderLinksLink() {
+    if (!this.props.conf.showLinks) {
+      return null;
+    }
+    return (
+      <li key="links">
+        <Link
+          to={{ pathname: '/project/links', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('project_links.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderPermissionsLink() {
+    if (!this.props.conf.showPermissions) {
+      return null;
+    }
+    return (
+      <li key="permissions">
+        <Link
+          to={{ pathname: '/project_roles', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('permissions.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderBackgroundTasksLink() {
+    if (!this.props.conf.showBackgroundTasks) {
+      return null;
+    }
+    return (
+      <li key="background_tasks">
+        <Link
+          to={{ pathname: '/project/background_tasks', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('background_tasks.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderUpdateKeyLink() {
+    if (!this.props.conf.showUpdateKey) {
+      return null;
+    }
+    return (
+      <li key="update_key">
+        <Link
+          to={{ pathname: '/project/key', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('update_key.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderDeletionLink() {
+    const { qualifier } = this.props.component;
+
+    if (!this.props.conf.showSettings) {
+      return null;
+    }
+
+    if (qualifier !== 'TRK' && qualifier !== 'VW' && qualifier !== 'APP') {
+      return null;
+    }
+
+    return (
+      <li key="project_delete">
+        <Link
+          to={{ pathname: '/project/deletion', query: { id: this.props.component.key } }}
+          activeClassName="active">
+          {translate('deletion.page')}
+        </Link>
+      </li>
+    );
+  }
+
+  renderExtension = ({ key, name }: ComponentExtension, isAdmin: boolean) => {
+    const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`;
+    return (
+      <li key={key}>
+        <Link to={{ pathname, query: { id: this.props.component.key } }} activeClassName="active">
+          {name}
+        </Link>
+      </li>
+    );
+  };
+
+  renderAdminExtensions() {
+    const extensions = this.props.conf.extensions || [];
+    return extensions.map(e => this.renderExtension(e, true));
+  }
+
+  renderExtensions() {
+    const extensions = this.props.component.extensions || [];
+    const withoutGovernance = extensions.filter(ext => ext.name !== 'Governance');
+    if (!withoutGovernance.length) {
+      return null;
+    }
+
+    return (
+      <li className="dropdown">
+        <a
+          className="dropdown-toggle"
+          id="component-navigation-more"
+          data-toggle="dropdown"
+          href="#">
+          {translate('more')}&nbsp;
+          <i className="icon-dropdown" />
+        </a>
+        <ul className="dropdown-menu">
+          {withoutGovernance.map(e => this.renderExtension(e, false))}
+        </ul>
+      </li>
+    );
+  }
+
+  render() {
+    return (
+      <NavBarTabs>
+        {this.renderDashboardLink()}
+        {this.renderIssuesLink()}
+        {this.renderComponentMeasuresLink()}
+        {this.renderCodeLink()}
+        {this.renderActivityLink()}
+        {this.renderAdministration()}
+        {this.renderExtensions()}
+      </NavBarTabs>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.js
deleted file mode 100644 (file)
index 28133dd..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-import DateTimeFormatter from '../../../../components/intl/DateTimeFormatter';
-import IncrementalBadge from './IncrementalBadge';
-import PendingIcon from '../../../../components/shared/pending-icon';
-import Tooltip from '../../../../components/controls/Tooltip';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-
-export default function ComponentNavMeta(props) {
-  const metaList = [];
-  const canSeeBackgroundTasks = props.conf.showBackgroundTasks;
-  const backgroundTasksUrl =
-    window.baseUrl + `/project/background_tasks?id=${encodeURIComponent(props.component.key)}`;
-
-  if (props.isInProgress) {
-    const tooltip = canSeeBackgroundTasks
-      ? translateWithParameters('component_navigation.status.in_progress.admin', backgroundTasksUrl)
-      : translate('component_navigation.status.in_progress');
-    metaList.push(
-      <Tooltip
-        key="isInProgress"
-        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
-        mouseLeaveDelay={2}>
-        <li>
-          <i className="spinner" style={{ marginTop: '-1px' }} />{' '}
-          <span className="text-info">{translate('background_task.status.IN_PROGRESS')}</span>
-        </li>
-      </Tooltip>
-    );
-  } else if (props.isPending) {
-    const tooltip = canSeeBackgroundTasks
-      ? translateWithParameters('component_navigation.status.pending.admin', backgroundTasksUrl)
-      : translate('component_navigation.status.pending');
-    metaList.push(
-      <Tooltip
-        key="isPending"
-        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
-        mouseLeaveDelay={2}>
-        <li>
-          <PendingIcon /> <span>{translate('background_task.status.PENDING')}</span>
-        </li>
-      </Tooltip>
-    );
-  } else if (props.isFailed) {
-    const tooltip = canSeeBackgroundTasks
-      ? translateWithParameters('component_navigation.status.failed.admin', backgroundTasksUrl)
-      : translate('component_navigation.status.failed');
-    metaList.push(
-      <Tooltip
-        key="isFailed"
-        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
-        mouseLeaveDelay={2}>
-        <li>
-          <span className="badge badge-danger">
-            {translate('background_task.status.FAILED')}
-          </span>
-        </li>
-      </Tooltip>
-    );
-  }
-  if (props.analysisDate) {
-    metaList.push(
-      <li key="analysisDate">
-        <DateTimeFormatter date={props.analysisDate} />
-      </li>
-    );
-  }
-
-  if (props.version) {
-    metaList.push(
-      <li key="version">
-        Version {props.version}
-      </li>
-    );
-  }
-
-  if (props.incremental) {
-    metaList.push(
-      <li key="incremental">
-        <IncrementalBadge />
-      </li>
-    );
-  }
-
-  return (
-    <div className="navbar-context-meta">
-      <ul className="list-inline">
-        {metaList}
-      </ul>
-    </div>
-  );
-}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
new file mode 100644 (file)
index 0000000..fae37ab
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 IncrementalBadge from './IncrementalBadge';
+import BranchStatus from './BranchStatus';
+import { Branch, Component, ComponentConfiguration } from '../../../types';
+import Tooltip from '../../../../components/controls/Tooltip';
+import PendingIcon from '../../../../components/icons-components/PendingIcon';
+import DateTimeFormatter from '../../../../components/intl/DateTimeFormatter';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
+
+interface Props {
+  branch: Branch;
+  component: Component;
+  conf: ComponentConfiguration;
+  incremental?: boolean;
+  isInProgress?: boolean;
+  isFailed?: boolean;
+  isPending?: boolean;
+}
+
+export default function ComponentNavMeta(props: Props) {
+  const metaList = [];
+  const canSeeBackgroundTasks = props.conf.showBackgroundTasks;
+  const backgroundTasksUrl =
+    (window as any).baseUrl +
+    `/project/background_tasks?id=${encodeURIComponent(props.component.key)}`;
+
+  if (props.isInProgress) {
+    const tooltip = canSeeBackgroundTasks
+      ? translateWithParameters('component_navigation.status.in_progress.admin', backgroundTasksUrl)
+      : translate('component_navigation.status.in_progress');
+    metaList.push(
+      <Tooltip
+        key="isInProgress"
+        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
+        mouseLeaveDelay={2}>
+        <li>
+          <i className="spinner" style={{ marginTop: '-1px' }} />{' '}
+          <span className="text-info">{translate('background_task.status.IN_PROGRESS')}</span>
+        </li>
+      </Tooltip>
+    );
+  } else if (props.isPending) {
+    const tooltip = canSeeBackgroundTasks
+      ? translateWithParameters('component_navigation.status.pending.admin', backgroundTasksUrl)
+      : translate('component_navigation.status.pending');
+    metaList.push(
+      <Tooltip
+        key="isPending"
+        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
+        mouseLeaveDelay={2}>
+        <li>
+          <PendingIcon /> <span>{translate('background_task.status.PENDING')}</span>
+        </li>
+      </Tooltip>
+    );
+  } else if (props.isFailed) {
+    const tooltip = canSeeBackgroundTasks
+      ? translateWithParameters('component_navigation.status.failed.admin', backgroundTasksUrl)
+      : translate('component_navigation.status.failed');
+    metaList.push(
+      <Tooltip
+        key="isFailed"
+        overlay={<div dangerouslySetInnerHTML={{ __html: tooltip }} />}
+        mouseLeaveDelay={2}>
+        <li>
+          <span className="badge badge-danger">
+            {translate('background_task.status.FAILED')}
+          </span>
+        </li>
+      </Tooltip>
+    );
+  }
+
+  if (props.component.analysisDate && props.branch.isMain) {
+    metaList.push(
+      <li key="analysisDate">
+        <DateTimeFormatter date={props.component.analysisDate} />
+      </li>
+    );
+  }
+
+  if (props.component.version && props.branch.isMain) {
+    metaList.push(
+      <li key="version">
+        Version {props.component.version}
+      </li>
+    );
+  }
+
+  if (props.incremental) {
+    metaList.push(
+      <li key="incremental">
+        <IncrementalBadge />
+      </li>
+    );
+  }
+
+  if (!props.branch.isMain) {
+    metaList.push(
+      <li className="navbar-context-meta-branch" key="branch-status">
+        <BranchStatus branch={props.branch} />
+      </li>
+    );
+  }
+
+  return (
+    <div className="navbar-context-meta">
+      <ul className="list-inline">
+        {metaList}
+      </ul>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.js b/server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.js
deleted file mode 100644 (file)
index 2692360..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-import Tooltip from '../../../../components/controls/Tooltip';
-import { translate } from '../../../../helpers/l10n';
-
-export default function IncrementalBadge() {
-  return (
-    <Tooltip overlay={translate('incremental.project_tooltip')}>
-      <div className="outline-badge">
-        {translate('incremental')}
-      </div>
-    </Tooltip>
-  );
-}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.tsx b/server/sonar-web/src/main/js/app/components/nav/component/IncrementalBadge.tsx
new file mode 100644 (file)
index 0000000..0ac87aa
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 Tooltip from '../../../../components/controls/Tooltip';
+import { translate } from '../../../../helpers/l10n';
+
+export default function IncrementalBadge() {
+  return (
+    <Tooltip overlay={translate('incremental.project_tooltip')}>
+      <div className="outline-badge">
+        {translate('incremental')}
+      </div>
+    </Tooltip>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/BranchStatus-test.tsx
new file mode 100644 (file)
index 0000000..e932d89
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import BranchStatus from '../BranchStatus';
+import { BranchType } from '../../../../types';
+
+it('renders', () => {
+  check(0, 0, 0);
+  check(0, 1, 0);
+  check(7, 3, 6);
+});
+
+function check(bugs: number, codeSmells: number, vulnerabilities: number) {
+  expect(
+    shallow(
+      <BranchStatus
+        branch={{
+          isMain: false,
+          name: 'foo',
+          status: { bugs, codeSmells, vulnerabilities },
+          type: BranchType.SHORT
+        }}
+      />
+    )
+  ).toMatchSnapshot();
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranch-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranch-test.tsx
new file mode 100644 (file)
index 0000000..8b138e9
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ComponentNavBranch from '../ComponentNavBranch';
+import { BranchType, ShortLivingBranch, MainBranch, Component } from '../../../../types';
+import { click } from '../../../../../helpers/testUtils';
+
+it('renders main branch', () => {
+  const branch: MainBranch = { isMain: true, name: undefined, type: BranchType.LONG };
+  const component = {} as Component;
+  expect(shallow(<ComponentNavBranch branch={branch} project={component} />)).toMatchSnapshot();
+});
+
+it('renders short-living branch', () => {
+  const branch: ShortLivingBranch = {
+    isMain: false,
+    name: 'foo',
+    status: { bugs: 0, codeSmells: 0, vulnerabilities: 0 },
+    type: BranchType.SHORT
+  };
+  const component = {} as Component;
+  expect(shallow(<ComponentNavBranch branch={branch} project={component} />)).toMatchSnapshot();
+});
+
+it('opens menu', () => {
+  const branch: MainBranch = { isMain: true, name: undefined, type: BranchType.LONG };
+  const component = {} as Component;
+  const wrapper = shallow(<ComponentNavBranch branch={branch} project={component} />);
+  expect(wrapper.find('ComponentNavBranchesMenu')).toHaveLength(0);
+  click(wrapper.find('a'));
+  expect(wrapper.find('ComponentNavBranchesMenu')).toHaveLength(1);
+});
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx
new file mode 100644 (file)
index 0000000..c6ef473
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ComponentNavBranchesMenu from '../ComponentNavBranchesMenu';
+import {
+  BranchType,
+  MainBranch,
+  ShortLivingBranch,
+  LongLivingBranch,
+  Component
+} from '../../../../types';
+import { elementKeydown } from '../../../../../helpers/testUtils';
+
+it('renders list', () => {
+  const component = { key: 'component' } as Component;
+  const wrapper = shallow(
+    <ComponentNavBranchesMenu branch={mainBranch()} onClose={jest.fn()} project={component} />
+  );
+  wrapper.setState({
+    branches: [mainBranch(), shortBranch('foo'), longBranch('bar')],
+    loading: false
+  });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('searches', () => {
+  const component = { key: 'component' } as Component;
+  const wrapper = shallow(
+    <ComponentNavBranchesMenu branch={mainBranch()} onClose={jest.fn()} project={component} />
+  );
+  wrapper.setState({
+    branches: [mainBranch(), shortBranch('foo'), shortBranch('foobar'), longBranch('bar')],
+    loading: false,
+    query: 'bar'
+  });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('selects next & previous', () => {
+  const component = { key: 'component' } as Component;
+  const wrapper = shallow(
+    <ComponentNavBranchesMenu branch={mainBranch()} onClose={jest.fn()} project={component} />
+  );
+  wrapper.setState({
+    branches: [mainBranch(), shortBranch('foo'), shortBranch('foobar'), longBranch('bar')],
+    loading: false
+  });
+  elementKeydown(wrapper.find('input'), 40);
+  wrapper.update();
+  expect(wrapper.state().selected).toBe('foo');
+  elementKeydown(wrapper.find('input'), 40);
+  wrapper.update();
+  expect(wrapper.state().selected).toBe('foobar');
+  elementKeydown(wrapper.find('input'), 38);
+  wrapper.update();
+  expect(wrapper.state().selected).toBe('foo');
+});
+
+function mainBranch(): MainBranch {
+  return { isMain: true, name: undefined, type: BranchType.LONG };
+}
+
+function shortBranch(name: string): ShortLivingBranch {
+  return {
+    isMain: false,
+    name,
+    status: { bugs: 0, codeSmells: 0, vulnerabilities: 0 },
+    type: BranchType.SHORT
+  };
+}
+
+function longBranch(name: string): LongLivingBranch {
+  return { isMain: false, name, type: BranchType.LONG };
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenuItem-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenuItem-test.tsx
new file mode 100644 (file)
index 0000000..ba32de8
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ComponentNavBranchesMenuItem from '../ComponentNavBranchesMenuItem';
+import { BranchType, MainBranch, ShortLivingBranch, Component } from '../../../../types';
+
+it('renders main branch', () => {
+  const component = { key: 'component' } as Component;
+  const mainBranch: MainBranch = { isMain: true, name: undefined, type: BranchType.LONG };
+  expect(
+    shallow(
+      <ComponentNavBranchesMenuItem
+        branch={mainBranch}
+        component={component}
+        onSelect={jest.fn()}
+        selected={false}
+      />
+    )
+  ).toMatchSnapshot();
+});
+
+it('renders short-living branch', () => {
+  const component = { key: 'component' } as Component;
+  const shortBranch: ShortLivingBranch = {
+    isMain: false,
+    name: 'foo',
+    status: { bugs: 1, codeSmells: 2, vulnerabilities: 3 },
+    type: BranchType.SHORT
+  };
+  expect(
+    shallow(
+      <ComponentNavBranchesMenuItem
+        branch={shortBranch}
+        component={component}
+        onSelect={jest.fn()}
+        selected={false}
+      />
+    )
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.js b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.js
deleted file mode 100644 (file)
index d1c1da6..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-import { shallow } from 'enzyme';
-import ComponentNavMenu from '../ComponentNavMenu';
-
-it('should work with extensions', () => {
-  const component = {
-    key: 'foo',
-    qualifier: 'TRK',
-    extensions: [{ key: 'component-foo', name: 'ComponentFoo' }]
-  };
-  const conf = {
-    showSettings: true,
-    extensions: [{ key: 'foo', name: 'Foo' }]
-  };
-  expect(shallow(<ComponentNavMenu component={component} conf={conf} />)).toMatchSnapshot();
-});
-
-it('should work with multiple extensions', () => {
-  const component = {
-    key: 'foo',
-    qualifier: 'TRK',
-    extensions: [
-      { key: 'component-foo', name: 'ComponentFoo' },
-      { key: 'component-bar', name: 'ComponentBar' }
-    ]
-  };
-  const conf = {
-    showSettings: true,
-    extensions: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]
-  };
-  expect(shallow(<ComponentNavMenu component={component} conf={conf} />)).toMatchSnapshot();
-});
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavMenu-test.tsx
new file mode 100644 (file)
index 0000000..dc3ce7d
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ComponentNavMenu from '../ComponentNavMenu';
+import { Branch, Component } from '../../../../types';
+
+it('should work with extensions', () => {
+  const component = {
+    key: 'foo',
+    qualifier: 'TRK',
+    extensions: [{ key: 'component-foo', name: 'ComponentFoo' }]
+  };
+  const conf = {
+    showSettings: true,
+    extensions: [{ key: 'foo', name: 'Foo' }]
+  };
+  expect(
+    shallow(
+      <ComponentNavMenu branch={{} as Branch} component={component as Component} conf={conf} />
+    )
+  ).toMatchSnapshot();
+});
+
+it('should work with multiple extensions', () => {
+  const component = {
+    key: 'foo',
+    qualifier: 'TRK',
+    extensions: [
+      { key: 'component-foo', name: 'ComponentFoo' },
+      { key: 'component-bar', name: 'ComponentBar' }
+    ]
+  };
+  const conf = {
+    showSettings: true,
+    extensions: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]
+  };
+  expect(
+    shallow(
+      <ComponentNavMenu branch={{} as Branch} component={component as Component} conf={conf} />
+    )
+  ).toMatchSnapshot();
+});
index e5f0a6ab6fa91d16dea4205affcd2c2db39508b8..766d5aff111bd4ad059e5fde1925ddb1eaa0d40b 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import ComponentNavMeta from '../ComponentNavMeta';
+import { Branch, Component } from '../../../../types';
 
 it('renders incremental badge', () => {
   check(true);
@@ -28,7 +29,12 @@ it('renders incremental badge', () => {
   function check(incremental: boolean) {
     expect(
       shallow(
-        <ComponentNavMeta component={{ key: 'foo' }} conf={{}} incremental={incremental} />
+        <ComponentNavMeta
+          branch={{} as Branch}
+          component={{ key: 'foo' } as Component}
+          conf={{}}
+          incremental={incremental}
+        />
       ).find('IncrementalBadge')
     ).toHaveLength(incremental ? 1 : 0);
   }
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/BranchStatus-test.tsx.snap
new file mode 100644 (file)
index 0000000..ab40c58
--- /dev/null
@@ -0,0 +1,91 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<ul
+  className="list-inline branch-status"
+>
+  <li>
+    <i
+      className="branch-status-indicator is-passed"
+    />
+  </li>
+  <li>
+    0
+    <BugIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    0
+    <VulnerabilityIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    0
+    <CodeSmellIcon
+      className="little-spacer-left"
+    />
+  </li>
+</ul>
+`;
+
+exports[`renders 2`] = `
+<ul
+  className="list-inline branch-status"
+>
+  <li>
+    <i
+      className="branch-status-indicator is-failed"
+    />
+  </li>
+  <li>
+    0
+    <BugIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    0
+    <VulnerabilityIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    1
+    <CodeSmellIcon
+      className="little-spacer-left"
+    />
+  </li>
+</ul>
+`;
+
+exports[`renders 3`] = `
+<ul
+  className="list-inline branch-status"
+>
+  <li>
+    <i
+      className="branch-status-indicator is-failed"
+    />
+  </li>
+  <li>
+    7
+    <BugIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    6
+    <VulnerabilityIcon
+      className="little-spacer-left"
+    />
+  </li>
+  <li>
+    3
+    <CodeSmellIcon
+      className="little-spacer-left"
+    />
+  </li>
+</ul>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranch-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranch-test.tsx.snap
new file mode 100644 (file)
index 0000000..3ead439
--- /dev/null
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders main branch 1`] = `
+<div
+  className="navbar-context-branches dropdown"
+>
+  <a
+    className="link-base-color link-no-underline"
+    href="#"
+    onClick={[Function]}
+  >
+    <BranchIcon
+      className="little-spacer-right"
+    />
+    master
+    <i
+      className="icon-dropdown little-spacer-left"
+    />
+  </a>
+</div>
+`;
+
+exports[`renders short-living branch 1`] = `
+<div
+  className="navbar-context-branches dropdown"
+>
+  <a
+    className="link-base-color link-no-underline"
+    href="#"
+    onClick={[Function]}
+  >
+    <BranchIcon
+      className="little-spacer-right"
+    />
+    foo
+    <i
+      className="icon-dropdown little-spacer-left"
+    />
+  </a>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap
new file mode 100644 (file)
index 0000000..dc05a41
--- /dev/null
@@ -0,0 +1,157 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders list 1`] = `
+<div
+  className="dropdown-menu dropdown-menu-shadow"
+>
+  <div>
+    <div
+      className="search-box menu-search"
+    >
+      <button
+        className="search-box-submit button-clean"
+      >
+        <i
+          className="icon-search-new"
+        />
+      </button>
+      <input
+        autoFocus={true}
+        className="search-box-input"
+        onChange={[Function]}
+        onKeyDown={[Function]}
+        placeholder="search_verb"
+        type="search"
+        value=""
+      />
+    </div>
+    <ul
+      className="menu"
+    >
+      <ComponentNavBranchesMenuItem
+        branch={
+          Object {
+            "isMain": true,
+            "name": undefined,
+            "type": "LONG",
+          }
+        }
+        component={
+          Object {
+            "key": "component",
+          }
+        }
+        onSelect={[Function]}
+        selected={true}
+      />
+      <ComponentNavBranchesMenuItem
+        branch={
+          Object {
+            "isMain": false,
+            "name": "foo",
+            "status": Object {
+              "bugs": 0,
+              "codeSmells": 0,
+              "vulnerabilities": 0,
+            },
+            "type": "SHORT",
+          }
+        }
+        component={
+          Object {
+            "key": "component",
+          }
+        }
+        onSelect={[Function]}
+        selected={false}
+      />
+      <ComponentNavBranchesMenuItem
+        branch={
+          Object {
+            "isMain": false,
+            "name": "bar",
+            "type": "LONG",
+          }
+        }
+        component={
+          Object {
+            "key": "component",
+          }
+        }
+        onSelect={[Function]}
+        selected={false}
+      />
+    </ul>
+  </div>
+</div>
+`;
+
+exports[`searches 1`] = `
+<div
+  className="dropdown-menu dropdown-menu-shadow"
+>
+  <div>
+    <div
+      className="search-box menu-search"
+    >
+      <button
+        className="search-box-submit button-clean"
+      >
+        <i
+          className="icon-search-new"
+        />
+      </button>
+      <input
+        autoFocus={true}
+        className="search-box-input"
+        onChange={[Function]}
+        onKeyDown={[Function]}
+        placeholder="search_verb"
+        type="search"
+        value="bar"
+      />
+    </div>
+    <ul
+      className="menu"
+    >
+      <ComponentNavBranchesMenuItem
+        branch={
+          Object {
+            "isMain": false,
+            "name": "foobar",
+            "status": Object {
+              "bugs": 0,
+              "codeSmells": 0,
+              "vulnerabilities": 0,
+            },
+            "type": "SHORT",
+          }
+        }
+        component={
+          Object {
+            "key": "component",
+          }
+        }
+        onSelect={[Function]}
+        selected={true}
+      />
+      <ComponentNavBranchesMenuItem
+        branch={
+          Object {
+            "isMain": false,
+            "name": "bar",
+            "type": "LONG",
+          }
+        }
+        component={
+          Object {
+            "key": "component",
+          }
+        }
+        onSelect={[Function]}
+        selected={false}
+      />
+    </ul>
+  </div>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenuItem-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenuItem-test.tsx.snap
new file mode 100644 (file)
index 0000000..e579bfa
--- /dev/null
@@ -0,0 +1,90 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders main branch 1`] = `
+<li
+  onMouseEnter={[Function]}
+>
+  <Link
+    className="navbar-context-meta-branch-menu-item"
+    onlyActiveOnIndex={false}
+    style={Object {}}
+    to={
+      Object {
+        "pathname": "/dashboard",
+        "query": Object {
+          "id": "component",
+        },
+      }
+    }
+  >
+    <div>
+      <BranchIcon
+        className="little-spacer-right"
+      />
+      master
+    </div>
+    <div
+      className="big-spacer-left note"
+    >
+      <BranchStatus
+        branch={
+          Object {
+            "isMain": true,
+            "name": undefined,
+            "type": "LONG",
+          }
+        }
+        concise={true}
+      />
+    </div>
+  </Link>
+</li>
+`;
+
+exports[`renders short-living branch 1`] = `
+<li
+  onMouseEnter={[Function]}
+>
+  <Link
+    className="navbar-context-meta-branch-menu-item"
+    onlyActiveOnIndex={false}
+    style={Object {}}
+    to={
+      Object {
+        "pathname": "/project/issues",
+        "query": Object {
+          "branch": "foo",
+          "id": "component",
+          "resolved": "false",
+        },
+      }
+    }
+  >
+    <div>
+      <BranchIcon
+        className="little-spacer-right big-spacer-left"
+      />
+      foo
+    </div>
+    <div
+      className="big-spacer-left note"
+    >
+      <BranchStatus
+        branch={
+          Object {
+            "isMain": false,
+            "name": "foo",
+            "status": Object {
+              "bugs": 1,
+              "codeSmells": 2,
+              "vulnerabilities": 3,
+            },
+            "type": "SHORT",
+          }
+        }
+        concise={true}
+      />
+    </div>
+  </Link>
+</li>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.js.snap
deleted file mode 100644 (file)
index 1772a83..0000000
+++ /dev/null
@@ -1,433 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should work with extensions 1`] = `
-<NavBarTabs>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      overview.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/project/issues",
-          "query": Object {
-            "id": "foo",
-            "resolved": "false",
-          },
-        }
-      }
-    >
-      issues.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/component_measures",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      layout.measures
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/code",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      code.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/project/activity",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      project_activity.page
-    </Link>
-  </li>
-  <li
-    className="dropdown"
-  >
-    <a
-      className="dropdown-toggle is-admin"
-      data-toggle="dropdown"
-      href="#"
-      id="component-navigation-admin"
-    >
-      layout.settings
-      Â 
-      <i
-        className="icon-dropdown"
-      />
-    </a>
-    <ul
-      className="dropdown-menu"
-    >
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/settings",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          project_settings.page
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/admin/extension/foo",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          Foo
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/deletion",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          deletion.page
-        </Link>
-      </li>
-    </ul>
-  </li>
-  <li
-    className="dropdown"
-  >
-    <a
-      className="dropdown-toggle"
-      data-toggle="dropdown"
-      href="#"
-      id="component-navigation-more"
-    >
-      more
-      Â 
-      <i
-        className="icon-dropdown"
-      />
-    </a>
-    <ul
-      className="dropdown-menu"
-    >
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/extension/component-foo",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          ComponentFoo
-        </Link>
-      </li>
-    </ul>
-  </li>
-</NavBarTabs>
-`;
-
-exports[`should work with multiple extensions 1`] = `
-<NavBarTabs>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/dashboard",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      overview.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/project/issues",
-          "query": Object {
-            "id": "foo",
-            "resolved": "false",
-          },
-        }
-      }
-    >
-      issues.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/component_measures",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      layout.measures
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/code",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      code.page
-    </Link>
-  </li>
-  <li>
-    <Link
-      activeClassName="active"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/project/activity",
-          "query": Object {
-            "id": "foo",
-          },
-        }
-      }
-    >
-      project_activity.page
-    </Link>
-  </li>
-  <li
-    className="dropdown"
-  >
-    <a
-      className="dropdown-toggle is-admin"
-      data-toggle="dropdown"
-      href="#"
-      id="component-navigation-admin"
-    >
-      layout.settings
-      Â 
-      <i
-        className="icon-dropdown"
-      />
-    </a>
-    <ul
-      className="dropdown-menu"
-    >
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/settings",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          project_settings.page
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/admin/extension/foo",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          Foo
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/admin/extension/bar",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          Bar
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/deletion",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          deletion.page
-        </Link>
-      </li>
-    </ul>
-  </li>
-  <li
-    className="dropdown"
-  >
-    <a
-      className="dropdown-toggle"
-      data-toggle="dropdown"
-      href="#"
-      id="component-navigation-more"
-    >
-      more
-      Â 
-      <i
-        className="icon-dropdown"
-      />
-    </a>
-    <ul
-      className="dropdown-menu"
-    >
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/extension/component-foo",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          ComponentFoo
-        </Link>
-      </li>
-      <li>
-        <Link
-          activeClassName="active"
-          onlyActiveOnIndex={false}
-          style={Object {}}
-          to={
-            Object {
-              "pathname": "/project/extension/component-bar",
-              "query": Object {
-                "id": "foo",
-              },
-            }
-          }
-        >
-          ComponentBar
-        </Link>
-      </li>
-    </ul>
-  </li>
-</NavBarTabs>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap
new file mode 100644 (file)
index 0000000..fac7593
--- /dev/null
@@ -0,0 +1,437 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should work with extensions 1`] = `
+<NavBarTabs>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      overview.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/project/issues",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+            "resolved": "false",
+          },
+        }
+      }
+    >
+      issues.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/component_measures",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      layout.measures
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/code",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      code.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/project/activity",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      project_activity.page
+    </Link>
+  </li>
+  <li
+    className="dropdown"
+  >
+    <a
+      className="dropdown-toggle is-admin"
+      data-toggle="dropdown"
+      href="#"
+      id="component-navigation-admin"
+    >
+      layout.settings
+      Â 
+      <i
+        className="icon-dropdown"
+      />
+    </a>
+    <ul
+      className="dropdown-menu"
+    >
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/settings",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          project_settings.page
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/admin/extension/foo",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          Foo
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/deletion",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          deletion.page
+        </Link>
+      </li>
+    </ul>
+  </li>
+  <li
+    className="dropdown"
+  >
+    <a
+      className="dropdown-toggle"
+      data-toggle="dropdown"
+      href="#"
+      id="component-navigation-more"
+    >
+      more
+      Â 
+      <i
+        className="icon-dropdown"
+      />
+    </a>
+    <ul
+      className="dropdown-menu"
+    >
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/extension/component-foo",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          ComponentFoo
+        </Link>
+      </li>
+    </ul>
+  </li>
+</NavBarTabs>
+`;
+
+exports[`should work with multiple extensions 1`] = `
+<NavBarTabs>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/dashboard",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      overview.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/project/issues",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+            "resolved": "false",
+          },
+        }
+      }
+    >
+      issues.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/component_measures",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      layout.measures
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/code",
+          "query": Object {
+            "branch": undefined,
+            "id": "foo",
+          },
+        }
+      }
+    >
+      code.page
+    </Link>
+  </li>
+  <li>
+    <Link
+      activeClassName="active"
+      onlyActiveOnIndex={false}
+      style={Object {}}
+      to={
+        Object {
+          "pathname": "/project/activity",
+          "query": Object {
+            "id": "foo",
+          },
+        }
+      }
+    >
+      project_activity.page
+    </Link>
+  </li>
+  <li
+    className="dropdown"
+  >
+    <a
+      className="dropdown-toggle is-admin"
+      data-toggle="dropdown"
+      href="#"
+      id="component-navigation-admin"
+    >
+      layout.settings
+      Â 
+      <i
+        className="icon-dropdown"
+      />
+    </a>
+    <ul
+      className="dropdown-menu"
+    >
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/settings",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          project_settings.page
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/admin/extension/foo",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          Foo
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/admin/extension/bar",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          Bar
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/deletion",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          deletion.page
+        </Link>
+      </li>
+    </ul>
+  </li>
+  <li
+    className="dropdown"
+  >
+    <a
+      className="dropdown-toggle"
+      data-toggle="dropdown"
+      href="#"
+      id="component-navigation-more"
+    >
+      more
+      Â 
+      <i
+        className="icon-dropdown"
+      />
+    </a>
+    <ul
+      className="dropdown-menu"
+    >
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/extension/component-foo",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          ComponentFoo
+        </Link>
+      </li>
+      <li>
+        <Link
+          activeClassName="active"
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/extension/component-bar",
+              "query": Object {
+                "id": "foo",
+              },
+            }
+          }
+        >
+          ComponentBar
+        </Link>
+      </li>
+    </ul>
+  </li>
+</NavBarTabs>
+`;
index f9297eabb830b8d6475fdcf25b4c0320f4dd83f8..7cdd0a20035c900bd1af99e4115d4554570e3d78 100644 (file)
@@ -93,5 +93,4 @@
   padding: 0;
   overflow-y: auto;
   overflow-x: hidden;
-  box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
 }
index 84c35015e948892fb0fc70e43d6c744c239e58a7..ea4592d3992813572fdcea3bf3f2d6481ffc1b6e 100644 (file)
@@ -354,7 +354,7 @@ export default class Search extends React.PureComponent {
         {this.state.open &&
           Object.keys(this.state.results).length > 0 &&
           <div
-            className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown"
+            className="dropdown-menu dropdown-menu-shadow dropdown-menu-right global-navbar-search-dropdown"
             ref={node => (this.node = node)}>
             <SearchResults
               allowMore={this.state.query.length !== 1}
index 9fa9496d36718ba92427464f312788467786de51..8128a3827e3c4259b72b715f8abd15e0d97656e4 100644 (file)
@@ -38,7 +38,6 @@ import {
 } from '../../../api/ce';
 import { updateTask, mapFiltersToParameters } from '../utils';
 /*:: import type { Task } from '../types'; */
-import { getComponent } from '../../../store/rootReducer';
 import '../background-tasks.css';
 import { fetchOrganizations } from '../../../store/rootActions';
 import { translate } from '../../../helpers/l10n';
@@ -257,12 +256,6 @@ class BackgroundTasksApp extends React.PureComponent {
   }
 }
 
-const mapStateToProps = (state, ownProps) => ({
-  component: ownProps.location.query.id
-    ? getComponent(state, ownProps.location.query.id)
-    : undefined
-});
-
 const mapDispatchToProps = { fetchOrganizations };
 
-export default connect(mapStateToProps, mapDispatchToProps)(BackgroundTasksApp);
+export default connect(null, mapDispatchToProps)(BackgroundTasksApp);
index 5a19455b17b67f5880839b2b2e9a2b8f5ffda76f..b2ef518c558a70f0f54fea372f1b66626afdbb03 100644 (file)
@@ -20,7 +20,7 @@
 /* @flow */
 import React from 'react';
 import { STATUSES } from './../constants';
-import PendingIcon from '../../../components/shared/pending-icon';
+import PendingIcon from '../../../components/icons-components/PendingIcon';
 import { translate } from '../../../helpers/l10n';
 /*:: import type { Task } from '../types'; */
 
index 3a0f994aa1cdd29741b49711ba2798cf60e28477..c59cac6bade37cf88ca3570af444c2f3edcfc448 100644 (file)
@@ -20,7 +20,6 @@
 import classNames from 'classnames';
 import React from 'react';
 import Helmet from 'react-helmet';
-import { connect } from 'react-redux';
 import Components from './Components';
 import Breadcrumbs from './Breadcrumbs';
 import SourceViewer from './../../../components/SourceViewer/SourceViewer';
@@ -33,11 +32,10 @@ import {
   parseError
 } from '../utils';
 import { addComponent, addComponentBreadcrumbs, clearBucket } from '../bucket';
-import { getComponent } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 import '../code.css';
 
-class App extends React.PureComponent {
+export default class App extends React.PureComponent {
   state = {
     loading: true,
     baseComponent: null,
@@ -75,7 +73,7 @@ class App extends React.PureComponent {
 
     this.setState({ loading: true });
     const isPortfolio = ['VW', 'SVW'].includes(component.qualifier);
-    retrieveComponentChildren(component.key, isPortfolio)
+    retrieveComponentChildren(component.key, isPortfolio, component.branch)
       .then(r => {
         addComponent(r.baseComponent);
         this.handleUpdate();
@@ -92,7 +90,7 @@ class App extends React.PureComponent {
     this.setState({ loading: true });
 
     const isPortfolio = ['VW', 'SVW'].includes(this.props.component.qualifier);
-    retrieveComponent(componentKey, isPortfolio)
+    retrieveComponent(componentKey, isPortfolio, this.props.component.branch)
       .then(r => {
         if (this.mounted) {
           if (['FIL', 'UTS'].includes(r.component.qualifier)) {
@@ -132,10 +130,10 @@ class App extends React.PureComponent {
     this.loadComponent(finalKey);
   }
 
-  handleLoadMore() {
+  handleLoadMore = () => {
     const { baseComponent, page } = this.state;
     const isPortfolio = ['VW', 'SVW'].includes(this.props.component.qualifier);
-    loadMoreChildren(baseComponent.key, page + 1, isPortfolio)
+    loadMoreChildren(baseComponent.key, page + 1, isPortfolio, this.props.component.branch)
       .then(r => {
         if (this.mounted) {
           this.setState({
@@ -148,16 +146,16 @@ class App extends React.PureComponent {
       .catch(e => {
         if (this.mounted) {
           this.setState({ loading: false });
-          parseError(e).then(this.handleError.bind(this));
+          parseError(e).then(this.handleError);
         }
       });
-  }
+  };
 
-  handleError(error) {
+  handleError = error => {
     if (this.mounted) {
       this.setState({ error });
     }
-  }
+  };
 
   render() {
     const { component, location } = this.props;
@@ -186,7 +184,7 @@ class App extends React.PureComponent {
             {error}
           </div>}
 
-        <Search location={location} component={component} onError={this.handleError.bind(this)} />
+        <Search location={location} component={component} onError={this.handleError} />
 
         <div className="code-components">
           {shouldShowBreadcrumbs &&
@@ -202,24 +200,14 @@ class App extends React.PureComponent {
             </div>}
 
           {shouldShowComponents &&
-            <ListFooter
-              count={components.length}
-              total={total}
-              loadMore={this.handleLoadMore.bind(this)}
-            />}
+            <ListFooter count={components.length} total={total} loadMore={this.handleLoadMore} />}
 
           {shouldShowSourceViewer &&
             <div className="spacer-top">
-              <SourceViewer component={sourceViewer.key} />
+              <SourceViewer branch={component.branch} component={sourceViewer.key} />
             </div>}
         </div>
       </div>
     );
   }
 }
-
-const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(App);
index e36ad13bcf6159b3b4830a6a70b39b4d519fc14d..4fe40dfa6181351b2e4ea26b1ab41020178dee62 100644 (file)
@@ -70,10 +70,10 @@ export default class Component extends React.PureComponent {
       switch (component.qualifier) {
         case 'FIL':
         case 'UTS':
-          componentAction = <ComponentPin component={component} />;
+          componentAction = <ComponentPin branch={rootComponent.branch} component={component} />;
           break;
         default:
-          componentAction = <ComponentDetach component={component} />;
+          componentAction = <ComponentDetach branch={rootComponent.branch} component={component} />;
       }
     }
 
index 5277be984271abab2e1a4a46215bcb22e9549877..30fccdfa7bf6342be117d8900485279d278dba76 100644 (file)
@@ -21,10 +21,10 @@ import React from 'react';
 import { Link } from 'react-router';
 import { translate } from '../../../helpers/l10n';
 
-export default function ComponentDetach({ component }) {
+export default function ComponentDetach({ component, branch }) {
   return (
     <Link
-      to={{ pathname: '/dashboard', query: { id: component.refKey || component.key } }}
+      to={{ pathname: '/dashboard', query: { branch, id: component.refKey || component.key } }}
       className="icon-detach"
       title={translate('code.open_component_page')}
     />
index bad7f4875372b322b45a95ba03dff60d4352774b..921aa3f724c582c903281c8149be59ae01212fb3 100644 (file)
@@ -71,7 +71,7 @@ const ComponentName = ({ component, rootComponent, previous, canBrowse }) => {
       </Link>
     );
   } else if (canBrowse) {
-    const query = { id: rootComponent.key };
+    const query = { id: rootComponent.key, branch: rootComponent.branch };
     if (component.key !== rootComponent.key) {
       Object.assign(query, { selected: component.key });
     }
index 7d0f9478c59cd1560013844713fdc4011c706bf3..641017207d7b99cd0979cb99914840edb7680904 100644 (file)
@@ -22,10 +22,10 @@ import Workspace from '../../../components/workspace/main';
 import PinIcon from '../../../components/shared/pin-icon';
 import { translate } from '../../../helpers/l10n';
 
-const ComponentPin = ({ component }) => {
+const ComponentPin = ({ branch, component }) => {
   const handleClick = e => {
     e.preventDefault();
-    Workspace.openComponent({ key: component.key });
+    Workspace.openComponent({ branch, key: component.key });
   };
 
   return (
index a34d87e3c9ed623f697fc61b40a488c52c49c4b5..4d772e7a3b18e5463906cd7bf2f31927cdbe6bb4 100644 (file)
@@ -46,7 +46,7 @@ export default class Search extends React.PureComponent {
   };
 
   componentWillMount() {
-    this.handleSearch = debounce(this.handleSearch.bind(this), 250);
+    this.handleSearch = debounce(this.handleSearch, 250);
   }
 
   componentDidMount() {
@@ -100,6 +100,7 @@ export default class Search extends React.PureComponent {
         this.context.router.push({
           pathname: '/code',
           query: {
+            branch: component.branch,
             id: component.key,
             selected: selected.key
           }
@@ -126,7 +127,7 @@ export default class Search extends React.PureComponent {
     }
   }
 
-  handleSearch(query) {
+  handleSearch = query => {
     // first time check if value has changed due to debounce
     if (this.mounted && this.checkInputValue(query)) {
       const { component, onError } = this.props;
@@ -135,7 +136,12 @@ export default class Search extends React.PureComponent {
       const isPortfolio = ['VW', 'SVW', 'APP'].includes(component.qualifier);
       const qualifiers = isPortfolio ? 'SVW,TRK' : 'BRC,UTS,FIL';
 
-      getTree(component.key, { q: query, s: 'qualifier,name', qualifiers })
+      getTree(component.key, {
+        branch: component.branch,
+        q: query,
+        s: 'qualifier,name',
+        qualifiers
+      })
         .then(r => {
           // second time check if value has change due to api request
           if (this.mounted && this.checkInputValue(query)) {
@@ -154,7 +160,7 @@ export default class Search extends React.PureComponent {
           }
         });
     }
-  }
+  };
 
   handleQueryChange(query) {
     this.setState({ query });
index fcffeff191c6f053c61eb39bed4b280553e5bac4..9eb0b5ef7c31c6f93edaeff4c47536cab37c8493 100644 (file)
@@ -22,7 +22,7 @@ import { RouterState, IndexRouteProps } from 'react-router';
 const routes = [
   {
     getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
-      import('./components/App').then(i => callback(null, { component: i.default }));
+      import('./components/App').then(i => callback(null, { component: (i as any).default }));
     }
   }
 ];
index 2a1f5552cc0e3a99e469ec5e332200127f06f798..52975be78ec1b23396df0993813373981427802a 100644 (file)
@@ -120,7 +120,7 @@ function getMetrics(isPortfolio) {
  * @param {boolean} isPortfolio
  * @returns {Promise}
  */
-function retrieveComponentBase(componentKey, isPortfolio) {
+function retrieveComponentBase(componentKey, isPortfolio, branch) {
   const existing = getComponentFromBucket(componentKey);
   if (existing) {
     return Promise.resolve(existing);
@@ -128,7 +128,7 @@ function retrieveComponentBase(componentKey, isPortfolio) {
 
   const metrics = getMetrics(isPortfolio);
 
-  return getComponent(componentKey, metrics).then(component => {
+  return getComponent(componentKey, metrics, branch).then(component => {
     addComponent(component);
     return component;
   });
@@ -139,7 +139,7 @@ function retrieveComponentBase(componentKey, isPortfolio) {
  * @param {boolean} isPortfolio
  * @returns {Promise}
  */
-export function retrieveComponentChildren(componentKey, isPortfolio) {
+export function retrieveComponentChildren(componentKey, isPortfolio, branch) {
   const existing = getComponentChildren(componentKey);
   if (existing) {
     return Promise.resolve({
@@ -151,7 +151,7 @@ export function retrieveComponentChildren(componentKey, isPortfolio) {
 
   const metrics = getMetrics(isPortfolio);
 
-  return getChildren(componentKey, metrics, { ps: PAGE_SIZE, s: 'qualifier,name' })
+  return getChildren(componentKey, metrics, { branch, ps: PAGE_SIZE, s: 'qualifier,name' })
     .then(prepareChildren)
     .then(expandRootDir(metrics))
     .then(r => {
@@ -162,13 +162,13 @@ export function retrieveComponentChildren(componentKey, isPortfolio) {
     });
 }
 
-function retrieveComponentBreadcrumbs(componentKey) {
+function retrieveComponentBreadcrumbs(componentKey, branch) {
   const existing = getComponentBreadcrumbs(componentKey);
   if (existing) {
     return Promise.resolve(existing);
   }
 
-  return getBreadcrumbs(componentKey).then(skipRootDir).then(breadcrumbs => {
+  return getBreadcrumbs(componentKey, branch).then(skipRootDir).then(breadcrumbs => {
     addComponentBreadcrumbs(componentKey, breadcrumbs);
     return breadcrumbs;
   });
@@ -179,11 +179,11 @@ function retrieveComponentBreadcrumbs(componentKey) {
  * @param {boolean} isPortfolio
  * @returns {Promise}
  */
-export function retrieveComponent(componentKey, isPortfolio) {
+export function retrieveComponent(componentKey, isPortfolio, branch) {
   return Promise.all([
-    retrieveComponentBase(componentKey, isPortfolio),
-    retrieveComponentChildren(componentKey, isPortfolio),
-    retrieveComponentBreadcrumbs(componentKey)
+    retrieveComponentBase(componentKey, isPortfolio, branch),
+    retrieveComponentChildren(componentKey, isPortfolio, branch),
+    retrieveComponentBreadcrumbs(componentKey, branch)
   ]).then(r => {
     return {
       component: r[0],
@@ -195,10 +195,10 @@ export function retrieveComponent(componentKey, isPortfolio) {
   });
 }
 
-export function loadMoreChildren(componentKey, page, isPortfolio) {
+export function loadMoreChildren(componentKey, page, isPortfolio, branch) {
   const metrics = getMetrics(isPortfolio);
 
-  return getChildren(componentKey, metrics, { ps: PAGE_SIZE, p: page })
+  return getChildren(componentKey, metrics, { branch, ps: PAGE_SIZE, p: page })
     .then(prepareChildren)
     .then(expandRootDir(metrics))
     .then(r => {
index f3d91451c473ed27b8ea20b249da9d3d3a40d89f..f823961bb6ff2942408cc4c02066439bb5eeffe5 100644 (file)
@@ -22,12 +22,7 @@ import { connect } from 'react-redux';
 import { withRouter } from 'react-router';
 import App from './App';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
-import {
-  getComponent,
-  getCurrentUser,
-  getMetrics,
-  getMetricsKey
-} from '../../../store/rootReducer';
+import { getCurrentUser, getMetrics, getMetricsKey } from '../../../store/rootReducer';
 import { fetchMetrics } from '../../../store/rootActions';
 import { getMeasuresAndMeta } from '../../../api/measures';
 import { getLeakPeriod } from '../../../helpers/periods';
@@ -35,8 +30,7 @@ import { enhanceMeasure } from '../../../components/measure/utils';
 /*:: import type { Component, Period } from '../types'; */
 /*:: import type { Measure, MeasureEnhanced } from '../../../components/measure/types'; */
 
-const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id),
+const mapStateToProps = state => ({
   currentUser: getCurrentUser(state),
   metrics: getMetrics(state),
   metricsKey: getMetricsKey(state)
index 1f40c41775ffa2f08395f4f01a42ff54286beea8..4f2f1ba1ca6121631007fde5ff477108f51e75ae 100644 (file)
  */
 import React from 'react';
 import Helmet from 'react-helmet';
-import { connect } from 'react-redux';
 import init from '../init';
-import { getComponent } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 
-class CustomMeasuresAppContainer extends React.PureComponent {
+export default class CustomMeasuresAppContainer extends React.PureComponent {
   componentDidMount() {
     init(this.refs.container, this.props.component);
   }
@@ -38,9 +36,3 @@ class CustomMeasuresAppContainer extends React.PureComponent {
     );
   }
 }
-
-const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(CustomMeasuresAppContainer);
index ad092ba1d4818a6f8e1e5298d8ac37344c41daec..cc0a32c95c1fcc2c48f5a196c2c4cd1c602d1023 100644 (file)
@@ -23,7 +23,7 @@ const routes = [
   {
     getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
       import('./components/CustomMeasuresAppContainer').then(i =>
-        callback(null, { component: i.default })
+        callback(null, { component: (i as any).default })
       );
     }
   }
index 4a0040f29910f0ac6e842f9f251417b1ca8bc473..509a69dddcecd463c9c0c03b21bedca141e84820 100644 (file)
@@ -63,6 +63,7 @@ import '../styles.css';
 
 /*::
 export type Props = {
+  branch?: { name: string },
   component?: Component,
   currentUser: CurrentUser,
   fetchIssues: (query: RawQuery) => Promise<*>,
@@ -171,6 +172,7 @@ export default class App extends React.PureComponent {
     const { query } = this.props.location;
     const { query: prevQuery } = prevProps.location;
     if (
+      prevProps.component !== this.props.component ||
       !areQueriesEqual(prevQuery, query) ||
       areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query)
     ) {
@@ -306,6 +308,7 @@ export default class App extends React.PureComponent {
       pathname: this.props.location.pathname,
       query: {
         ...serializeQuery(this.state.query),
+        branch: this.props.branch && this.props.branch.name,
         id: this.props.component && this.props.component.key,
         myIssues: this.state.myIssues ? 'true' : undefined,
         open: issue
@@ -324,6 +327,7 @@ export default class App extends React.PureComponent {
         pathname: this.props.location.pathname,
         query: {
           ...serializeQuery(this.state.query),
+          branch: this.props.branch && this.props.branch.name,
           id: this.props.component && this.props.component.key,
           myIssues: this.state.myIssues ? 'true' : undefined,
           open: undefined
@@ -359,6 +363,8 @@ export default class App extends React.PureComponent {
       : undefined;
 
     const parameters = {
+      branch: this.props.branch && this.props.branch.name,
+      componentKeys: component && component.key,
       s: 'FILE_LINE',
       ...serializeQuery(query),
       ps: '100',
@@ -367,10 +373,6 @@ export default class App extends React.PureComponent {
       ...additional
     };
 
-    if (component) {
-      Object.assign(parameters, { componentKeys: component.key });
-    }
-
     // only sorting by CREATION_DATE is allowed, so let's sort DESC
     if (query.sort) {
       Object.assign(parameters, { asc: 'false' });
@@ -552,6 +554,7 @@ export default class App extends React.PureComponent {
       pathname: this.props.location.pathname,
       query: {
         ...serializeQuery({ ...this.state.query, ...changes }),
+        branch: this.props.branch && this.props.branch.name,
         id: this.props.component && this.props.component.key,
         myIssues: this.state.myIssues ? 'true' : undefined
       }
@@ -567,6 +570,7 @@ export default class App extends React.PureComponent {
       pathname: this.props.location.pathname,
       query: {
         ...serializeQuery({ ...this.state.query, assigned: true, assignees: [] }),
+        branch: this.props.branch && this.props.branch.name,
         id: this.props.component && this.props.component.key,
         myIssues: myIssues ? 'true' : undefined
       }
@@ -593,6 +597,7 @@ export default class App extends React.PureComponent {
       pathname: this.props.location.pathname,
       query: {
         ...DEFAULT_QUERY,
+        branch: this.props.branch && this.props.branch.name,
         id: this.props.component && this.props.component.key,
         myIssues: this.state.myIssues ? 'true' : undefined
       }
@@ -885,6 +890,8 @@ export default class App extends React.PureComponent {
             <div>
               {openIssue
                 ? <IssuesSourceViewer
+                    branch={this.props.branch}
+                    component={component}
                     openIssue={openIssue}
                     loadIssues={this.fetchIssuesForComponent}
                     onIssueChange={this.handleIssueChange}
index 040f0c4718f46afe52f4e2701b4970b320a76f09..2f6b4368a87e3d10441e9d11fe16f81337d6551d 100644 (file)
@@ -24,17 +24,14 @@ import { withRouter } from 'react-router';
 import { uniq } from 'lodash';
 import App from './App';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
-import { getComponent, getCurrentUser } from '../../../store/rootReducer';
+import { getCurrentUser } from '../../../store/rootReducer';
 import { getOrganizations } from '../../../api/organizations';
 import { receiveOrganizations } from '../../../store/organizations/duck';
 import { searchIssues } from '../../../api/issues';
 import { parseIssueFromResponse } from '../../../helpers/issues';
 /*:: import type { RawQuery } from '../../../helpers/query'; */
 
-const mapStateToProps = (state, ownProps) => ({
-  component: ownProps.location.query.id
-    ? getComponent(state, ownProps.location.query.id)
-    : undefined,
+const mapStateToProps = state => ({
   currentUser: getCurrentUser(state)
 });
 
index 498f2e377ce23dacfed6113577bfdc6e91f155c9..6561fd547fa0a033e68703cf93249ef0e7f9aab9 100644 (file)
 import React from 'react';
 import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 import { scrollToElement } from '../../../helpers/scrolling';
+/*:: import type { Component, } from '../utils'; */
 /*:: import type { Issue } from '../../../components/issue/types'; */
 
 /*::
 type Props = {|
+  branch?: { name: string },
+  component: Component,
   loadIssues: (string, number, number) => Promise<*>,
   onIssueChange: Issue => void,
   onIssueSelect: string => void,
@@ -83,6 +86,7 @@ export default class IssuesSourceViewer extends React.PureComponent {
       <div ref={node => (this.node = node)}>
         <SourceViewer
           aroundLine={openIssue.textRange ? openIssue.textRange.endLine : undefined}
+          branch={this.props.branch && this.props.branch.name}
           component={openIssue.component}
           displayAllIssues={true}
           highlightedLocations={locations}
index 6170138afa2688fca51edc2bb389835ff20a7eea..951745679515d31804f400068e1e61c5571c4e31 100644 (file)
@@ -215,6 +215,10 @@ export default class CreationDateFacet extends React.PureComponent {
 
   renderPredefinedPeriods() {
     const { component, createdInLast, sinceLeakPeriod } = this.props;
+    if (component != null && component.branch != null) {
+      // FIXME handle long-living branches
+      return null;
+    }
     return (
       <div className="spacer-top issues-predefined-periods">
         <FacetItem
index 37e04ee37426c91451ca230ceb153a6716d929bd..aa36f8402aa309d11898097a7d32019840d00066 100644 (file)
@@ -22,10 +22,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import OverviewApp from './OverviewApp';
 import EmptyOverview from './EmptyOverview';
+import { isShortLivingBranch } from '../../../helpers/branches';
+import { getProjectBranchUrl } from '../../../helpers/urls';
 import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 
 /*::
 type Props = {
+  branch: {},
   component: {
     analysisDate?: string,
     id: string,
@@ -33,6 +36,7 @@ type Props = {
     qualifier: string,
     tags: Array<string>
   },
+  onComponentChange: {} => void,
   router: Object
 };
 */
@@ -52,6 +56,9 @@ export default class App extends React.PureComponent {
         query: { id: this.props.component.key }
       });
     }
+    if (isShortLivingBranch(this.props.branch)) {
+      this.context.router.replace(getProjectBranchUrl(this.props.component.key, this.props.branch));
+    }
   }
 
   isPortfolio() {
@@ -59,7 +66,7 @@ export default class App extends React.PureComponent {
   }
 
   render() {
-    if (this.isPortfolio()) {
+    if (this.isPortfolio() || isShortLivingBranch(this.props.branch)) {
       return null;
     }
 
@@ -77,6 +84,6 @@ export default class App extends React.PureComponent {
       return <EmptyOverview component={component} />;
     }
 
-    return <OverviewApp component={component} />;
+    return <OverviewApp component={component} onComponentChange={this.props.onComponentChange} />;
   }
 }
diff --git a/server/sonar-web/src/main/js/apps/overview/components/AppContainer.js b/server/sonar-web/src/main/js/apps/overview/components/AppContainer.js
deleted file mode 100644 (file)
index a6025ae..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 { connect } from 'react-redux';
-import App from './App';
-import { getComponent } from '../../../store/rootReducer';
-
-const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(App);
index ac67fbae1940bd069338f24709be9123ac1d5d16..9e28bf4838611365973bea2c741243657aa00952 100644 (file)
@@ -41,7 +41,8 @@ import '../styles.css';
 
 /*::
 type Props = {
-  component: Component
+  component: Component,
+  onComponentChange: {} => void
 };
 */
 
@@ -175,7 +176,12 @@ export default class OverviewApp extends React.PureComponent {
           </div>
 
           <div className="page-sidebar-fixed">
-            <Meta component={component} history={history} measures={measures} />
+            <Meta
+              component={component}
+              history={history}
+              measures={measures}
+              onComponentChange={this.props.onComponentChange}
+            />
           </div>
         </div>
       </div>
index fd849228480747cde20c2bbb79a2aa56db93e310..a79b44dbd410d59b334c6cbb2cadcfc65d52ae78 100644 (file)
@@ -30,7 +30,14 @@ import MetaSize from './MetaSize';
 import MetaTags from './MetaTags';
 import { areThereCustomOrganizations } from '../../../store/rootReducer';
 
-const Meta = ({ component, history, measures, areThereCustomOrganizations, router }) => {
+const Meta = ({
+  component,
+  history,
+  measures,
+  areThereCustomOrganizations,
+  onComponentChange,
+  router
+}) => {
   const { qualifier, description, qualityProfiles, qualityGate } = component;
 
   const isProject = qualifier === 'TRK';
@@ -53,7 +60,7 @@ const Meta = ({ component, history, measures, areThereCustomOrganizations, route
 
       <MetaSize component={component} measures={measures} />
 
-      {isProject && <MetaTags component={component} />}
+      {isProject && <MetaTags component={component} onComponentChange={onComponentChange} />}
 
       {(isProject || isApplication) &&
         <AnalysesList
index b9d67b8e474bda19e22644cd488a5b367eb55d26..7254f0e21973bc3254d47d4c84e6de4d22717c74 100644 (file)
  */
 //@flow
 import React from 'react';
+import { setProjectTags } from '../../../api/components';
 import { translate } from '../../../helpers/l10n';
 import TagsList from '../../../components/tags/TagsList';
-import ProjectTagsSelectorContainer from '../../projects/components/ProjectTagsSelectorContainer';
+import MetaTagsSelector from './MetaTagsSelector';
 
 /*::
 type Props = {
@@ -31,7 +32,8 @@ type Props = {
     configuration?: {
       showSettings?: boolean
     }
-  }
+  },
+  onComponentChange: {} => void
 };
 */
 
@@ -104,6 +106,13 @@ export default class MetaTags extends React.PureComponent {
     };
   }
 
+  handleSetProjectTags = (tags /*: Array<string> */) => {
+    setProjectTags({ project: this.props.component.key, tags: tags.join(',') }).then(
+      () => this.props.onComponentChange({ tags }),
+      () => {}
+    );
+  };
+
   render() {
     const { tags, key } = this.props.component;
     const { popupOpen, popupPosition } = this.state;
@@ -119,10 +128,11 @@ export default class MetaTags extends React.PureComponent {
           </button>
           {popupOpen &&
             <div ref={tagsSelector => (this.tagsSelector = tagsSelector)}>
-              <ProjectTagsSelectorContainer
+              <MetaTagsSelector
                 position={popupPosition}
                 project={key}
                 selectedTags={tags}
+                setProjectTags={this.handleSetProjectTags}
               />
             </div>}
         </div>
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js
new file mode 100644 (file)
index 0000000..76e25c9
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+//@flow
+import React from 'react';
+import { debounce, without } from 'lodash';
+import TagsSelector from '../../../components/tags/TagsSelector';
+import { searchProjectTags } from '../../../api/components';
+
+/*::
+type Props = {
+  position: {},
+  project: string,
+  selectedTags: Array<string>,
+  setProjectTags: (Array<string>) => void
+};
+*/
+
+/*::
+type State = {
+  searchResult: Array<string>
+};
+*/
+
+const LIST_SIZE = 10;
+
+export default class MetaTagsSelector extends React.PureComponent {
+  /*:: props: Props; */
+  /*:: state: State; */
+
+  constructor(props /*: Props */) {
+    super(props);
+    this.state = { searchResult: [] };
+    this.onSearch = debounce(this.onSearch, 250);
+  }
+
+  componentDidMount() {
+    this.onSearch('');
+  }
+
+  onSearch = (query /*: string */) => {
+    searchProjectTags({
+      q: query || '',
+      ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100)
+    }).then(result => {
+      this.setState({
+        searchResult: result.tags
+      });
+    });
+  };
+
+  onSelect = (tag /*: string */) => {
+    this.props.setProjectTags([...this.props.selectedTags, tag]);
+  };
+
+  onUnselect = (tag /*: string */) => {
+    this.props.setProjectTags(without(this.props.selectedTags, tag));
+  };
+
+  render() {
+    return (
+      <TagsSelector
+        position={this.props.position}
+        tags={this.state.searchResult}
+        selectedTags={this.props.selectedTags}
+        listSize={LIST_SIZE}
+        onSearch={this.onSearch}
+        onSelect={this.onSelect}
+        onUnselect={this.onUnselect}
+      />
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTagsSelector-test.js b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTagsSelector-test.js
new file mode 100644 (file)
index 0000000..59744a2
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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.
+ */
+/* eslint-disable import/order, import/first */
+import * as React from 'react';
+import { mount, shallow } from 'enzyme';
+import MetaTagsSelector from '../MetaTagsSelector';
+
+jest.mock('../../../../api/components', () => ({
+  searchProjectTags: jest.fn()
+}));
+
+jest.useFakeTimers();
+
+import { searchProjectTags } from '../../../../api/components';
+
+it('searches tags on mount', () => {
+  searchProjectTags.mockImplementation(() => Promise.resolve({ tags: ['foo', 'bar'] }));
+
+  mount(
+    <MetaTagsSelector position={{}} project="foo" selectedTags={[]} setProjectTags={jest.fn()} />
+  );
+  jest.runAllTimers();
+
+  expect(searchProjectTags).toBeCalledWith({ ps: 9, q: '' });
+});
+
+it('selects and deselects tags', () => {
+  const setProjectTags = jest.fn();
+  const wrapper = shallow(
+    <MetaTagsSelector
+      position={{}}
+      project="foo"
+      selectedTags={['foo', 'bar']}
+      setProjectTags={setProjectTags}
+    />
+  );
+
+  wrapper.find('TagsSelector').prop('onSelect')('baz');
+  expect(setProjectTags).toHaveBeenLastCalledWith(['foo', 'bar', 'baz']);
+
+  // note that the `selectedTags` is a prop and so it wasn't changed
+  wrapper.find('TagsSelector').prop('onUnselect')('bar');
+  expect(setProjectTags).toHaveBeenLastCalledWith(['foo']);
+});
index 0eb1415ab0318f1f21fa0ebda036daa78042f04b..875302462d597ce3b7496b7b83dfb06e2d1ee255 100644 (file)
@@ -40,7 +40,7 @@ exports[`should open the tag selector on click 2`] = `
     />
   </button>
   <div>
-    <Connect(ProjectTagsSelectorContainer)
+    <MetaTagsSelector
       position={
         Object {
           "right": 0,
@@ -54,6 +54,7 @@ exports[`should open the tag selector on click 2`] = `
           "bar",
         ]
       }
+      setProjectTags={[Function]}
     />
   </div>
 </div>
index 21e41c6b0875fde7da854f4d086187f0c15a6daf..9eb0b5ef7c31c6f93edaeff4c47536cab37c8493 100644 (file)
@@ -22,7 +22,7 @@ import { RouterState, IndexRouteProps } from 'react-router';
 const routes = [
   {
     getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
-      import('./components/AppContainer').then(i => callback(null, { component: i.default }));
+      import('./components/App').then(i => callback(null, { component: (i as any).default }));
     }
   }
 ];
index 5aff00dd956dab2c662b4f51b5323c2cfdbb2c79..9cd6a47ea96f71845f0eb3bd61f3cc9080d357d8 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
-import PropTypes from 'prop-types';
 import Helmet from 'react-helmet';
-import { connect } from 'react-redux';
 import Header from './Header';
 import Form from './Form';
-import { getComponent } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 
-class Deletion extends React.PureComponent {
-  static propTypes = {
-    component: PropTypes.object
-  };
-
-  render() {
-    if (!this.props.component) {
-      return null;
-    }
-
-    return (
-      <div className="page page-limited">
-        <Helmet title={translate('deletion.page')} />
-        <Header component={this.props.component} />
-        <Form component={this.props.component} />
-      </div>
-    );
-  }
+export default function Deletion(props) {
+  return (
+    <div className="page page-limited">
+      <Helmet title={translate('deletion.page')} />
+      <Header component={props.component} />
+      <Form component={props.component} />
+    </div>
+  );
 }
-
-const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(Deletion);
index 130d67cd4c27df64e2120ae2bb26715e2010d18d..4a340ab637142c11551bbc7900e41635f5d3c502 100644 (file)
@@ -35,11 +35,11 @@ import {
 import { parseError } from '../../code/utils';
 import { reloadUpdateKeyPage } from './utils';
 import RecentHistory from '../../../app/components/RecentHistory';
-import { getProjectAdminProjectModules, getComponent } from '../../../store/rootReducer';
+import { getProjectAdminProjectModules } from '../../../store/rootReducer';
 
 class Key extends React.PureComponent {
   static propTypes = {
-    component: PropTypes.object.isRequired,
+    component: PropTypes.object,
     fetchProjectModules: PropTypes.func.isRequired,
     changeKey: PropTypes.func.isRequired,
     addGlobalErrorMessage: PropTypes.func.isRequired,
@@ -141,7 +141,6 @@ class Key extends React.PureComponent {
 }
 
 const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id),
   modules: getProjectAdminProjectModules(state, ownProps.location.query.id)
 });
 
index fede0404e07f558cacfea12a307859953405a894..592ae5fc092c2882ff0a9d977126d830e37d8797 100644 (file)
@@ -25,12 +25,12 @@ import Header from './Header';
 import Table from './Table';
 import DeletionModal from './views/DeletionModal';
 import { fetchProjectLinks, deleteProjectLink, createProjectLink } from '../store/actions';
-import { getProjectAdminProjectLinks, getComponent } from '../../../store/rootReducer';
+import { getProjectAdminProjectLinks } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 
 class Links extends React.PureComponent {
   static propTypes = {
-    component: PropTypes.object.isRequired,
+    component: PropTypes.object,
     links: PropTypes.array
   };
 
@@ -67,7 +67,6 @@ class Links extends React.PureComponent {
 }
 
 const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id),
   links: getProjectAdminProjectLinks(state, ownProps.location.query.id)
 });
 
index f07fbed428b2869002791fc34c8305f530fcc4c6..6194c04371afcf52953db4ff96882ad78c916de8 100644 (file)
@@ -24,16 +24,12 @@ import { connect } from 'react-redux';
 import Header from './Header';
 import Form from './Form';
 import { fetchProjectGate, setProjectGate } from '../store/actions';
-import {
-  getProjectAdminAllGates,
-  getProjectAdminProjectGate,
-  getComponent
-} from '../../../store/rootReducer';
+import { getProjectAdminAllGates, getProjectAdminProjectGate } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 
 class QualityGate extends React.PureComponent {
   static propTypes = {
-    component: PropTypes.object.isRequired,
+    component: PropTypes.object,
     allGates: PropTypes.array,
     gate: PropTypes.object
   };
@@ -62,7 +58,6 @@ class QualityGate extends React.PureComponent {
 }
 
 const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id),
   allGates: getProjectAdminAllGates(state),
   gate: getProjectAdminProjectGate(state, ownProps.location.query.id)
 });
index 4551f410a25943074a26ba0ceaaa10434ff7c07f..02ff3c3eac55cd2ad9e76ce69f47009155d53df5 100644 (file)
@@ -27,8 +27,7 @@ import { fetchProjectProfiles, setProjectProfile } from '../store/actions';
 import {
   areThereCustomOrganizations,
   getProjectAdminAllProfiles,
-  getProjectAdminProjectProfiles,
-  getComponent
+  getProjectAdminProjectProfiles
 } from '../../../store/rootReducer';
 import { translate } from '../../../helpers/l10n';
 
@@ -80,7 +79,6 @@ class QualityProfiles extends React.PureComponent {
 }
 
 const mapStateToProps = (state, ownProps) => ({
-  component: getComponent(state, ownProps.location.query.id),
   customOrganizations: areThereCustomOrganizations(state),
   allProfiles: getProjectAdminAllProfiles(state),
   profiles: getProjectAdminProjectProfiles(state, ownProps.location.query.id)
index 1d3c9845ecd2ba31018d6a97bc234b81bc592ce8..de7cde52b7a0e1c5283227c992bfac37661bc3f2 100644 (file)
  */
 // @flow
 import React from 'react';
-import { connect } from 'react-redux';
-import { withRouter } from 'react-router';
+import PropTypes from 'prop-types';
 import ProjectActivityApp from './ProjectActivityApp';
 import throwGlobalError from '../../../app/utils/throwGlobalError';
-import { getComponent } from '../../../store/rootReducer';
 import { getAllTimeMachineData } from '../../../api/time-machine';
 import { getMetrics } from '../../../api/metrics';
 import * as api from '../../../api/projectActivity';
@@ -45,15 +43,11 @@ import {
 /*::
 type Props = {
   location: { pathname: string, query: RawQuery },
-  project: {
+  component: {
     configuration?: { showHistory: boolean },
     key: string,
     leakPeriodDate: string,
     qualifier: string
-  },
-  router: {
-    push: ({ pathname: string, query?: RawQuery }) => void,
-    replace: ({ pathname: string, query?: RawQuery }) => void
   }
 };
 */
@@ -71,11 +65,15 @@ export type State = {
 };
 */
 
-class ProjectActivityAppContainer extends React.PureComponent {
+export default class ProjectActivityAppContainer extends React.PureComponent {
   /*:: mounted: boolean; */
   /*:: props: Props; */
   /*:: state: State; */
 
+  static contextTypes = {
+    router: PropTypes.object
+  };
+
   constructor(props /*: Props */) {
     super(props);
     this.state = {
@@ -93,7 +91,7 @@ class ProjectActivityAppContainer extends React.PureComponent {
       if (isCustomGraph(newQuery.graph)) {
         newQuery.customMetrics = getCustomGraph();
       }
-      this.props.router.replace({
+      this.context.router.replace({
         pathname: props.location.pathname,
         query: serializeUrlQuery(newQuery)
       });
@@ -182,7 +180,7 @@ class ProjectActivityAppContainer extends React.PureComponent {
     if (metrics.length <= 0) {
       return Promise.resolve([]);
     }
-    return getAllTimeMachineData(this.props.project.key, metrics).then(
+    return getAllTimeMachineData(this.props.component.key, metrics).then(
       ({ measures }) =>
         measures.map(measure => ({
           metric: measure.metric,
@@ -279,11 +277,11 @@ class ProjectActivityAppContainer extends React.PureComponent {
       ...this.state.query,
       ...newQuery
     });
-    this.props.router.push({
+    this.context.router.push({
       pathname: this.props.location.pathname,
       query: {
         ...query,
-        id: this.props.project.key
+        id: this.props.component.key
       }
     });
   };
@@ -319,16 +317,10 @@ class ProjectActivityAppContainer extends React.PureComponent {
         initializing={!this.state.initialized}
         metrics={this.state.metrics}
         measuresHistory={this.state.measuresHistory}
-        project={this.props.project}
+        project={this.props.component}
         query={this.state.query}
         updateQuery={this.updateQuery}
       />
     );
   }
 }
-
-const mapStateToProps = (state, ownProps) => ({
-  project: getComponent(state, ownProps.location.query.id)
-});
-
-export default connect(mapStateToProps)(withRouter(ProjectActivityAppContainer));
index 47d7bda3dae95d8a3e2b2b82f52507b64074313b..8641ea7391cc60b73de9a901a0e6987ae1010e65 100644 (file)
@@ -23,7 +23,7 @@ const routes = [
   {
     getIndexRoute(_: RouterState, callback: (err: any, route: IndexRouteProps) => any) {
       import('./components/ProjectActivityAppContainer').then(i =>
-        callback(null, { component: i.default })
+        callback(null, { component: (i as any).default })
       );
     }
   }
diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js b/server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js
deleted file mode 100644 (file)
index 2cb4ab4..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-//@flow
-import React from 'react';
-import { connect } from 'react-redux';
-import { debounce, without } from 'lodash';
-import TagsSelector from '../../../components/tags/TagsSelector';
-import { searchProjectTags } from '../../../api/components';
-import { setProjectTags } from '../store/actions';
-
-/*::
-type Props = {
-  position: {},
-  project: string,
-  selectedTags: Array<string>,
-  setProjectTags: (string, Array<string>) => void
-};
-*/
-
-/*::
-type State = {
-  searchResult: Array<string>
-};
-*/
-
-const LIST_SIZE = 10;
-
-class ProjectTagsSelectorContainer extends React.PureComponent {
-  /*:: props: Props; */
-  /*:: state: State; */
-
-  constructor(props /*: Props */) {
-    super(props);
-    this.state = { searchResult: [] };
-    this.onSearch = debounce(this.onSearch, 250);
-  }
-
-  componentDidMount() {
-    this.onSearch('');
-  }
-
-  onSearch = (query /*: string */) => {
-    searchProjectTags({
-      q: query || '',
-      ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100)
-    }).then(result => {
-      this.setState({
-        searchResult: result.tags
-      });
-    });
-  };
-
-  onSelect = (tag /*: string */) => {
-    this.props.setProjectTags(this.props.project, [...this.props.selectedTags, tag]);
-  };
-
-  onUnselect = (tag /*: string */) => {
-    this.props.setProjectTags(this.props.project, without(this.props.selectedTags, tag));
-  };
-
-  render() {
-    return (
-      <TagsSelector
-        position={this.props.position}
-        tags={this.state.searchResult}
-        selectedTags={this.props.selectedTags}
-        listSize={LIST_SIZE}
-        onSearch={this.onSearch}
-        onSelect={this.onSelect}
-        onUnselect={this.onUnselect}
-      />
-    );
-  }
-}
-
-export default connect(null, { setProjectTags })(ProjectTagsSelectorContainer);
index 8e80ca6cb6fd3513338f1106f92ac788f17dd51c..cbc69d53cfa9043338f8c3f91ebee42ef75152f6 100644 (file)
 import { connect } from 'react-redux';
 import App from './App';
 import { fetchSettings } from '../store/actions';
-import { getComponent, getSettingsAppDefaultCategory } from '../../../store/rootReducer';
+import { getSettingsAppDefaultCategory } from '../../../store/rootReducer';
 
-const mapStateToProps = (state, ownProps) => ({
-  component: ownProps.location.query.id
-    ? getComponent(state, ownProps.location.query.id)
-    : undefined,
+const mapStateToProps = state => ({
   defaultCategory: getSettingsAppDefaultCategory(state)
 });
 
index d656865b0ec616d62dc66a1110d5d67b0c3ccc4a..8c72221dd3c49d915b76f046ce3c34f9a948115d 100644 (file)
@@ -54,15 +54,16 @@ import './styles.css';
 /*::
 type Props = {
   aroundLine?: number,
+  branch?: string,
   component: string,
   displayAllIssues: boolean,
   filterLine?: (line: SourceLine) => boolean,
   highlightedLine?: number,
   highlightedLocations?: Array<FlowLocation>,
   highlightedLocationMessage?: { index: number, text: string },
-  loadComponent: string => Promise<*>,
-  loadIssues: (string, number, number) => Promise<*>,
-  loadSources: (string, number, number) => Promise<*>,
+  loadComponent: (component: string, branch?: string) => Promise<*>,
+  loadIssues: (component: string, from: number, to: number, branch?: string) => Promise<*>,
+  loadSources: (component: string, from: number, to: number, branch?: string) => Promise<*>,
   onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void,
   onLocationSelect?: number => void,
   onIssueChange?: Issue => void,
@@ -112,16 +113,17 @@ type State = {
 
 const LINES = 500;
 
-function loadComponent(key /*: string */) /*: Promise<*> */ {
-  return getComponentForSourceViewer(key);
+function loadComponent(key /*: string */, branch /*: string | void */) /*: Promise<*> */ {
+  return getComponentForSourceViewer(key, branch);
 }
 
 function loadSources(
   key /*: string */,
   from /*: ?number */,
-  to /*: ?number */
+  to /*: ?number */,
+  branch /*: string | void */
 ) /*: Promise<Array<*>> */ {
-  return getSources(key, from, to);
+  return getSources(key, from, to, branch);
 }
 
 export default class SourceViewerBase extends React.PureComponent {
@@ -175,7 +177,7 @@ export default class SourceViewerBase extends React.PureComponent {
   }
 
   componentDidUpdate(prevProps /*: Props */) {
-    if (prevProps.component !== this.props.component) {
+    if (prevProps.component !== this.props.component || prevProps.branch !== this.props.branch) {
       this.fetchComponent();
     } else if (
       this.props.aroundLine != null &&
@@ -227,7 +229,7 @@ export default class SourceViewerBase extends React.PureComponent {
   fetchComponent() {
     this.setState({ loading: true });
     const loadIssues = (component, sources) => {
-      this.props.loadIssues(this.props.component, 1, LINES).then(issues => {
+      this.props.loadIssues(this.props.component, 1, LINES, this.props.branch).then(issues => {
         if (this.mounted) {
           const finalSources = sources.slice(0, LINES);
           this.setState(
@@ -278,7 +280,9 @@ export default class SourceViewerBase extends React.PureComponent {
       );
     };
 
-    this.props.loadComponent(this.props.component).then(onResolve, onFailLoadComponent);
+    this.props
+      .loadComponent(this.props.component, this.props.branch)
+      .then(onResolve, onFailLoadComponent);
   }
 
   fetchSources() {
@@ -344,7 +348,7 @@ export default class SourceViewerBase extends React.PureComponent {
       to++;
 
       return this.props
-        .loadSources(this.props.component, from, to)
+        .loadSources(this.props.component, from, to, this.props.branch)
         .then(sources => resolve(sources), onFailLoadSources);
     });
   }
@@ -356,23 +360,25 @@ export default class SourceViewerBase extends React.PureComponent {
     const firstSourceLine = this.state.sources[0];
     this.setState({ loadingSourcesBefore: true });
     const from = Math.max(1, firstSourceLine.line - LINES);
-    this.props.loadSources(this.props.component, from, firstSourceLine.line - 1).then(sources => {
-      this.props.loadIssues(this.props.component, from, firstSourceLine.line - 1).then(issues => {
-        if (this.mounted) {
-          this.setState(prevState => {
-            const nextIssues = uniqBy([...issues, ...prevState.issues], issue => issue.key);
-            return {
-              issues: nextIssues,
-              issuesByLine: issuesByLine(nextIssues),
-              issueLocationsByLine: locationsByLine(nextIssues),
-              loadingSourcesBefore: false,
-              sources: [...this.computeCoverageStatus(sources), ...prevState.sources],
-              symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) }
-            };
-          });
-        }
+    this.props
+      .loadSources(this.props.component, from, firstSourceLine.line - 1, this.props.branch)
+      .then(sources => {
+        this.props.loadIssues(this.props.component, from, firstSourceLine.line - 1).then(issues => {
+          if (this.mounted) {
+            this.setState(prevState => {
+              const nextIssues = uniqBy([...issues, ...prevState.issues], issue => issue.key);
+              return {
+                issues: nextIssues,
+                issuesByLine: issuesByLine(nextIssues),
+                issueLocationsByLine: locationsByLine(nextIssues),
+                loadingSourcesBefore: false,
+                sources: [...this.computeCoverageStatus(sources), ...prevState.sources],
+                symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) }
+              };
+            });
+          }
+        });
       });
-    });
   };
 
   loadSourcesAfter = () => {
@@ -384,30 +390,32 @@ export default class SourceViewerBase extends React.PureComponent {
     const fromLine = lastSourceLine.line + 1;
     // request one additional line to define `hasSourcesAfter`
     const toLine = lastSourceLine.line + LINES + 1;
-    this.props.loadSources(this.props.component, fromLine, toLine).then(sources => {
-      this.props.loadIssues(this.props.component, fromLine, toLine).then(issues => {
-        if (this.mounted) {
-          this.setState(prevState => {
-            const nextIssues = uniqBy([...prevState.issues, ...issues], issue => issue.key);
-            return {
-              issues: nextIssues,
-              issuesByLine: issuesByLine(nextIssues),
-              issueLocationsByLine: locationsByLine(nextIssues),
-              hasSourcesAfter: sources.length > LINES,
-              loadingSourcesAfter: false,
-              sources: [
-                ...prevState.sources,
-                ...this.computeCoverageStatus(sources.slice(0, LINES))
-              ],
-              symbolsByLine: {
-                ...prevState.symbolsByLine,
-                ...symbolsByLine(sources.slice(0, LINES))
-              }
-            };
-          });
-        }
+    this.props
+      .loadSources(this.props.component, fromLine, toLine, this.props.branch)
+      .then(sources => {
+        this.props.loadIssues(this.props.component, fromLine, toLine).then(issues => {
+          if (this.mounted) {
+            this.setState(prevState => {
+              const nextIssues = uniqBy([...prevState.issues, ...issues], issue => issue.key);
+              return {
+                issues: nextIssues,
+                issuesByLine: issuesByLine(nextIssues),
+                issueLocationsByLine: locationsByLine(nextIssues),
+                hasSourcesAfter: sources.length > LINES,
+                loadingSourcesAfter: false,
+                sources: [
+                  ...prevState.sources,
+                  ...this.computeCoverageStatus(sources.slice(0, LINES))
+                ],
+                symbolsByLine: {
+                  ...prevState.symbolsByLine,
+                  ...symbolsByLine(sources.slice(0, LINES))
+                }
+              };
+            });
+          }
+        });
       });
-    });
   };
 
   loadDuplications = (line /*: SourceLine */) => {
index 8d86b7c4a19e24b4cf36c287b2b925213a05a6ab..be548183d6e2e7a137ba3b8a53d4e27fcd4900d0 100644 (file)
@@ -22,7 +22,7 @@ import { searchIssues } from '../../../api/issues';
 import { parseIssueFromResponse } from '../../../helpers/issues';
 
 /*::
-export type Query = { [string]: string };
+export type Query = { [string]: string | void };
 */
 
 /*::
@@ -31,11 +31,12 @@ export type Issues = Array<*>; */
 // maximum possible value
 const PAGE_SIZE = 500;
 
-function buildQuery(component /*: string */) /*: Query */ {
+function buildQuery(component /*: string */, branch /*: string | void */) /*: Query */ {
   return {
     additionalFields: '_all',
     resolved: 'false',
     componentKeys: component,
+    branch,
     s: 'FILE_LINE'
   };
 }
@@ -80,17 +81,16 @@ export function loadPageAndNext(
   });
 }
 
-function loadIssues(
+export default function loadIssues(
   component /*: string */,
   fromLine /*: number */,
-  toLine /*: number */
+  toLine /*: number */,
+  branch /*: string | void */
 ) /*: Promise<Issues> */ {
-  const query = buildQuery(component);
+  const query = buildQuery(component, branch);
   return new Promise(resolve => {
     loadPageAndNext(query, toLine, 1).then(issues => {
       resolve(issues);
     });
   });
 }
-
-export default loadIssues;
diff --git a/server/sonar-web/src/main/js/components/icons-components/BranchIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/BranchIcon.tsx
new file mode 100644 (file)
index 0000000..420d94e
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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';
+
+interface Props {
+  className?: string;
+  color?: string;
+  size?: number;
+}
+
+export default function BranchIcon({ className, color = '#4b9fd5', size = 14 }: Props) {
+  /* eslint-disable max-len */
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      className={className}
+      height={size}
+      width={size}
+      viewBox="0 0 16 16">
+      <g transform="matrix(0.0416667,0,0,0.0416667,2.98284,-1.32102)">
+        <path
+          d="M72,368C72,361.333 69.667,355.667 65,351C60.333,346.333 54.667,344 48,344C41.333,344 35.667,346.333 31,351C26.333,355.667 24,361.333 24,368C24,374.667 26.333,380.333 31,385C35.667,389.667 41.333,392 48,392C54.667,392 60.333,389.667 65,385C69.667,380.333 72,374.667 72,368ZM72,80C72,73.333 69.667,67.667 65,63C60.333,58.333 54.667,56 48,56C41.333,56 35.667,58.333 31,63C26.333,67.667 24,73.333 24,80C24,86.667 26.333,92.333 31,97C35.667,101.667 41.333,104 48,104C54.667,104 60.333,101.667 65,97C69.667,92.333 72,86.667 72,80ZM232,112C232,105.333 229.667,99.667 225,95C220.333,90.333 214.667,88 208,88C201.333,88 195.667,90.333 191,95C186.333,99.667 184,105.333 184,112C184,118.667 186.333,124.333 191,129C195.667,133.667 201.333,136 208,136C214.667,136 220.333,133.667 225,129C229.667,124.333 232,118.667 232,112ZM256,112C256,120.667 253.833,128.708 249.5,136.125C245.167,143.542 239.333,149.333 232,153.5C231.667,201.333 212.833,235.833 175.5,257C164.167,263.333 147.25,270.083 124.75,277.25C103.417,283.917 89.292,289.833 82.375,295C75.458,300.167 72,308.5 72,320L72,326.5C79.333,330.667 85.167,336.458 89.5,343.875C93.833,351.292 96,359.333 96,368C96,381.333 91.333,392.667 82,402C72.667,411.333 61.333,416 48,416C34.667,416 23.333,411.333 14,402C4.667,392.667 0,381.333 0,368C0,359.333 2.167,351.292 6.5,343.875C10.833,336.458 16.667,330.667 24,326.5L24,121.5C16.667,117.333 10.833,111.542 6.5,104.125C2.167,96.708 0,88.667 0,80C0,66.667 4.667,55.333 14,46C23.333,36.667 34.667,32 48,32C61.333,32 72.667,36.667 82,46C91.333,55.333 96,66.667 96,80C96,88.667 93.833,96.708 89.5,104.125C85.167,111.542 79.333,117.333 72,121.5L72,245.75C81,241.417 93.833,236.667 110.5,231.5C119.667,228.667 126.958,226.208 132.375,224.125C137.792,222.042 143.667,219.458 150,216.375C156.333,213.292 161.25,210 164.75,206.5C168.25,203 171.625,198.75 174.875,193.75C178.125,188.75 180.458,182.958 181.875,176.375C183.292,169.792 184,162.167 184,153.5C176.667,149.333 170.833,143.542 166.5,136.125C162.167,128.708 160,120.667 160,112C160,98.667 164.667,87.333 174,78C183.333,68.667 194.667,64 208,64C221.333,64 232.667,68.667 242,78C251.333,87.333 256,98.667 256,112Z"
+          style={{ fill: color, fillRule: 'nonzero' }}
+        />
+      </g>
+    </svg>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/icons-components/PendingIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/PendingIcon.tsx
new file mode 100644 (file)
index 0000000..74140a3
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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';
+
+export default function PendingIcon() {
+  /* eslint max-len: 0 */
+  return (
+    <svg width="16" height="16" className="icon-pending">
+      <g transform="matrix(0.0364583,0,0,0.0364583,1,-0.166667)">
+        <path d="M224,136L224,248C224,250.333 223.25,252.25 221.75,253.75C220.25,255.25 218.333,256 216,256L136,256C133.667,256 131.75,255.25 130.25,253.75C128.75,252.25 128,250.333 128,248L128,232C128,229.667 128.75,227.75 130.25,226.25C131.75,224.75 133.667,224 136,224L192,224L192,136C192,133.667 192.75,131.75 194.25,130.25C195.75,128.75 197.667,128 200,128L216,128C218.333,128 220.25,128.75 221.75,130.25C223.25,131.75 224,133.667 224,136ZM328,224C328,199.333 321.917,176.583 309.75,155.75C297.583,134.917 281.083,118.417 260.25,106.25C239.417,94.083 216.667,88 192,88C167.333,88 144.583,94.083 123.75,106.25C102.917,118.417 86.417,134.917 74.25,155.75C62.083,176.583 56,199.333 56,224C56,248.667 62.083,271.417 74.25,292.25C86.417,313.083 102.917,329.583 123.75,341.75C144.583,353.917 167.333,360 192,360C216.667,360 239.417,353.917 260.25,341.75C281.083,329.583 297.583,313.083 309.75,292.25C321.917,271.417 328,248.667 328,224ZM384,224C384,258.833 375.417,290.958 358.25,320.375C341.083,349.792 317.792,373.083 288.375,390.25C258.958,407.417 226.833,416 192,416C157.167,416 125.042,407.417 95.625,390.25C66.208,373.083 42.917,349.792 25.75,320.375C8.583,290.958 0,258.833 0,224C0,189.167 8.583,157.042 25.75,127.625C42.917,98.208 66.208,74.917 95.625,57.75C125.042,40.583 157.167,32 192,32C226.833,32 258.958,40.583 288.375,57.75C317.792,74.917 341.083,98.208 358.25,127.625C375.417,157.042 384,189.167 384,224Z" />
+      </g>
+    </svg>
+  );
+}
index ebebf69464a6090afa0b87d45f8b403f9a11bbb9..25f9470753afc781dc21f3e44ab8b37df2274015 100644 (file)
@@ -10,6 +10,7 @@
 }
 
 .navbar-context-header {
+  float: left;
   line-height: 30px;
   font-size: 15px;
 }
diff --git a/server/sonar-web/src/main/js/components/nav/ContextNavBar.js b/server/sonar-web/src/main/js/components/nav/ContextNavBar.js
deleted file mode 100644 (file)
index 20690b5..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import classNames from 'classnames';
-import NavBar from './NavBar';
-import './ContextNavBar.css';
-
-/*::
-type Props = {
-  className?: string,
-  height: number
-};
-*/
-
-export default function ContextNavBar({ className, ...other } /*: Props */) {
-  return <NavBar className={classNames('navbar-context', className)} {...other} />;
-}
diff --git a/server/sonar-web/src/main/js/components/nav/ContextNavBar.tsx b/server/sonar-web/src/main/js/components/nav/ContextNavBar.tsx
new file mode 100644 (file)
index 0000000..de83625
--- /dev/null
@@ -0,0 +1,33 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 classNames from 'classnames';
+import NavBar from './NavBar';
+import './ContextNavBar.css';
+
+interface Props {
+  className?: string;
+  height: number;
+  [attr: string]: any;
+}
+
+export default function ContextNavBar({ className, ...other }: Props) {
+  return <NavBar className={classNames('navbar-context', className)} {...other} />;
+}
diff --git a/server/sonar-web/src/main/js/components/nav/NavBar.js b/server/sonar-web/src/main/js/components/nav/NavBar.js
deleted file mode 100644 (file)
index 180ca0d..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import classNames from 'classnames';
-import './NavBar.css';
-
-/*::
-type Props = {
-  children?: React.Element<*>,
-  className?: string,
-  height: number
-};
-*/
-
-export default function NavBar({ children, className, height, ...other } /*: Props */) {
-  return (
-    <nav {...other} className={classNames('navbar', className)} style={{ height }}>
-      <div className="navbar-inner" style={{ height }}>
-        <div className="navbar-limited clearfix">
-          {children}
-        </div>
-      </div>
-    </nav>
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/nav/NavBar.tsx b/server/sonar-web/src/main/js/components/nav/NavBar.tsx
new file mode 100644 (file)
index 0000000..08d0e18
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 classNames from 'classnames';
+import './NavBar.css';
+
+interface Props {
+  children?: any;
+  className?: string;
+  height: number;
+}
+
+export default function NavBar({ children, className, height, ...other }: Props) {
+  return (
+    <nav {...other} className={classNames('navbar', className)} style={{ height }}>
+      <div className="navbar-inner" style={{ height }}>
+        <div className="navbar-limited clearfix">
+          {children}
+        </div>
+      </div>
+    </nav>
+  );
+}
index b716dad62a387aefb027435f3c9540a006641525..366e8d15998b1c9c250ad85447e4b1b61541dd7f 100644 (file)
@@ -1,6 +1,7 @@
 .navbar-tabs {
   display: flex;
   align-items: center;
+  clear: left;
 }
 
 .navbar-tabs > li + li {
diff --git a/server/sonar-web/src/main/js/components/nav/NavBarTabs.js b/server/sonar-web/src/main/js/components/nav/NavBarTabs.js
deleted file mode 100644 (file)
index 4f03f4c..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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.
- */
-// @flow
-import React from 'react';
-import classNames from 'classnames';
-import './NavBarTabs.css';
-
-/*::
-type Props = {
-  children?: React.Element<*>,
-  className?: string
-};
-*/
-
-export default function NavBarTabs({ children, className, ...other } /*: Props */) {
-  return (
-    <ul {...other} className={classNames('navbar-tabs', className)}>
-      {children}
-    </ul>
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/nav/NavBarTabs.tsx b/server/sonar-web/src/main/js/components/nav/NavBarTabs.tsx
new file mode 100644 (file)
index 0000000..621c3af
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2017 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 classNames from 'classnames';
+import './NavBarTabs.css';
+
+interface Props {
+  children?: any;
+  className?: string;
+  [attr: string]: any;
+}
+
+export default function NavBarTabs({ children, className, ...other }: Props) {
+  return (
+    <ul {...other} className={classNames('navbar-tabs', className)}>
+      {children}
+    </ul>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/shared/pending-icon.js b/server/sonar-web/src/main/js/components/shared/pending-icon.js
deleted file mode 100644 (file)
index 006eddd..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2017 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 React from 'react';
-
-export default class PendingIcon extends React.PureComponent {
-  render() {
-    /* eslint max-len: 0 */
-    return (
-      <svg width="16" height="16" className="icon-pending">
-        <g transform="matrix(0.0364583,0,0,0.0364583,1,-0.166667)">
-          <path d="M224,136L224,248C224,250.333 223.25,252.25 221.75,253.75C220.25,255.25 218.333,256 216,256L136,256C133.667,256 131.75,255.25 130.25,253.75C128.75,252.25 128,250.333 128,248L128,232C128,229.667 128.75,227.75 130.25,226.25C131.75,224.75 133.667,224 136,224L192,224L192,136C192,133.667 192.75,131.75 194.25,130.25C195.75,128.75 197.667,128 200,128L216,128C218.333,128 220.25,128.75 221.75,130.25C223.25,131.75 224,133.667 224,136ZM328,224C328,199.333 321.917,176.583 309.75,155.75C297.583,134.917 281.083,118.417 260.25,106.25C239.417,94.083 216.667,88 192,88C167.333,88 144.583,94.083 123.75,106.25C102.917,118.417 86.417,134.917 74.25,155.75C62.083,176.583 56,199.333 56,224C56,248.667 62.083,271.417 74.25,292.25C86.417,313.083 102.917,329.583 123.75,341.75C144.583,353.917 167.333,360 192,360C216.667,360 239.417,353.917 260.25,341.75C281.083,329.583 297.583,313.083 309.75,292.25C321.917,271.417 328,248.667 328,224ZM384,224C384,258.833 375.417,290.958 358.25,320.375C341.083,349.792 317.792,373.083 288.375,390.25C258.958,407.417 226.833,416 192,416C157.167,416 125.042,407.417 95.625,390.25C66.208,373.083 42.917,349.792 25.75,320.375C8.583,290.958 0,258.833 0,224C0,189.167 8.583,157.042 25.75,127.625C42.917,98.208 66.208,74.917 95.625,57.75C125.042,40.583 157.167,32 192,32C226.833,32 258.958,40.583 288.375,57.75C317.792,74.917 341.083,98.208 358.25,127.625C375.417,157.042 384,189.167 384,224Z" />
-        </g>
-      </svg>
-    );
-  }
-}
index e0dda6033f7b5efaf5b3d33ae5137c0d2cd0cba5..80c2f55df107dd9f9d266ef800889691c6057ce4 100644 (file)
@@ -49,13 +49,14 @@ export default BaseView.extend({
   },
 
   showViewer() {
-    const { key, line } = this.model.toJSON();
+    const { branch, key, line } = this.model.toJSON();
 
     const el = document.querySelector(this.viewerRegion.el);
 
     render(
       <WithStore>
         <SourceViewer
+          branch={branch}
           component={key}
           fromWorkspace={true}
           highlightedLine={line}
diff --git a/server/sonar-web/src/main/js/helpers/branches.ts b/server/sonar-web/src/main/js/helpers/branches.ts
new file mode 100644 (file)
index 0000000..f447020
--- /dev/null
@@ -0,0 +1,36 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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 { Branch, BranchType, ShortLivingBranch } from '../app/types';
+
+export const MAIN_BRANCH: Branch = {
+  isMain: true,
+  name: undefined,
+  type: BranchType.LONG
+};
+
+const MAIN_BRANCH_DISPLAY_NAME = 'master';
+
+export function isShortLivingBranch(branch: Branch | null): branch is ShortLivingBranch {
+  return branch != null && branch.type === BranchType.SHORT;
+}
+
+export function getBranchDisplayName(branch: Branch): string {
+  return branch.isMain ? MAIN_BRANCH_DISPLAY_NAME : branch.name;
+}
index 3cf8cfbb6fd9cf610d58f357ade1ead03be9e703..4f7f62e72fca65d12ae533548dc5b6ec244771b3 100644 (file)
@@ -18,7 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { stringify } from 'querystring';
+import { isShortLivingBranch } from './branches';
 import { getProfilePath } from '../apps/quality-profiles/utils';
+import { Branch } from '../app/types';
 
 interface Query {
   [x: string]: string;
@@ -40,6 +42,17 @@ export function getProjectUrl(key: string): Location {
   return { pathname: '/dashboard', query: { id: key } };
 }
 
+export function getProjectBranchUrl(key: string, branch: Branch) {
+  if (isShortLivingBranch(branch)) {
+    return {
+      pathname: '/project/issues',
+      query: { branch: branch.name, id: key, resolved: 'false' }
+    };
+  } else {
+    return { pathname: '/dashboard', query: { id: key } };
+  }
+}
+
 /**
  * Generate URL for a global issues page
  */
index 5185ad9701e6b2299e3189073f0a50d0827478a9..a698193de3283815e75bd62cfac2c2cef96215e1 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { getLanguages } from '../api/languages';
-import { getGlobalNavigation, getComponentNavigation } from '../api/nav';
-import { getComponentData } from '../api/components';
+import { getGlobalNavigation } from '../api/nav';
 import * as auth from '../api/auth';
 import { getOrganizations } from '../api/organizations';
 import { getMetrics } from '../api/metrics';
 import { receiveLanguages } from './languages/actions';
-import { receiveComponents } from './components/actions';
 import { receiveMetrics } from './metrics/actions';
 import { addGlobalErrorMessage } from './globalMessages/duck';
 import { parseError } from '../apps/code/utils';
@@ -49,23 +47,6 @@ export const fetchOrganizations = (organizations /*: Array<string> | void */) =>
     onFail(dispatch)
   );
 
-const addQualifier = project => ({
-  ...project,
-  qualifier: project.breadcrumbs[project.breadcrumbs.length - 1].qualifier
-});
-
-export const fetchProject = key => dispatch =>
-  Promise.all([
-    getComponentNavigation(key),
-    getComponentData(key)
-  ]).then(([componentNav, componentData]) => {
-    const component = { ...componentData, ...componentNav };
-    dispatch(receiveComponents([addQualifier(component)]));
-    if (component.organization != null) {
-      dispatch(fetchOrganizations([component.organization]));
-    }
-  });
-
 export const doLogin = (login, password) => dispatch =>
   auth.login(login, password).then(
     () => {
index e9cef542a0f627d1ef2e3cc66ac169df7c7346fb..6213f652aa469d004249f033757482aeb50202c2 100644 (file)
   right: auto;
 }
 
+.dropdown-menu-shadow {
+  box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+}
+
 .dropdown-header {
   display: block;
   padding: 3px 8px 5px;
index 362d85a3b76fe89efe3352ff8be40af63114d4f6..bf751939d5d783f09faad8370f997477287306cf 100644 (file)
     }
   }
 }
+
+.menu-message {
+  display: block;
+  padding: 4px 16px;
+  line-height: 16px;
+}