]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9756 update branch management page
authorStas Vilchik <stas.vilchik@sonarsource.com>
Wed, 27 Sep 2017 13:08:22 +0000 (15:08 +0200)
committerDaniel Schwarz <bartfastiel@users.noreply.github.com>
Tue, 3 Oct 2017 06:47:46 +0000 (08:47 +0200)
36 files changed:
server/sonar-web/src/main/js/api/settings.ts
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMenu.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMenu-test.tsx.snap
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js
server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js
server/sonar-web/src/main/js/apps/projectBranches/components/App.tsx
server/sonar-web/src/main/js/apps/projectBranches/components/AppContainer.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/BranchRow.tsx
server/sonar-web/src/main/js/apps/projectBranches/components/LeakPeriodForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPattern.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPatternForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/SettingForm.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/BranchRow-test.tsx
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPattern-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPatternForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/SettingForm-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/App-test.tsx.snap
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/BranchRow-test.tsx.snap
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPattern-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPatternForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/SettingForm-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectBranches/routes.ts
server/sonar-web/src/main/js/apps/settings/components/App.js
server/sonar-web/src/main/js/apps/settings/components/CategoriesList.js
server/sonar-web/src/main/js/apps/settings/components/Definition.js
server/sonar-web/src/main/js/apps/settings/components/DefinitionsList.js
server/sonar-web/src/main/js/apps/settings/components/PageHeader.js
server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.js
server/sonar-web/src/main/js/apps/settings/store/actions.js
server/sonar-web/src/main/js/components/icons-components/SettingsIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/less/components/modals.less
server/sonar-web/src/main/less/init/icons.less
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ecadfadc87f8bb2414b2464b034244aaec1eccdd..dd257cea597507abd8c0b4b1a0d6cf8c89180045 100644 (file)
 import { omitBy } from 'lodash';
 import { getJSON, RequestData, post, postJSON } from '../helpers/request';
 import { TYPE_PROPERTY_SET } from '../apps/settings/constants';
+import throwGlobalError from '../app/utils/throwGlobalError';
 
 export function getDefinitions(component: string | null, branch?: string): Promise<any> {
   return getJSON('/api/settings/list_definitions', { branch, component }).then(r => r.definitions);
 }
 
-export function getValues(keys: string, component?: string, branch?: string): Promise<any> {
+export interface SettingValue {
+  inherited?: boolean;
+  key: string;
+  parentValue?: string;
+  parentValues?: string[];
+  value?: any;
+  values?: string[];
+}
+
+export function getValues(
+  keys: string,
+  component?: string,
+  branch?: string
+): Promise<SettingValue[]> {
   return getJSON('/api/settings/values', { keys, component, branch }).then(r => r.settings);
 }
 
@@ -51,6 +65,15 @@ export function setSettingValue(
   return post('/api/settings/set', data);
 }
 
+export function setSimpleSettingValue(parameters: {
+  branch?: string;
+  component?: string;
+  value: string;
+  key: string;
+}): Promise<void | Response> {
+  return post('/api/settings/set', parameters).catch(throwGlobalError);
+}
+
 export function resetSettingValue(key: string, component?: string, branch?: string): Promise<void> {
   return post('/api/settings/reset', { keys: key, component, branch });
 }
index 9cc10de252ec0a068ebd642571b1cdbd2072fec2..8e118add3ef3fb83f8a14ab5d8bc4c05d67c3659 100644 (file)
@@ -196,7 +196,7 @@ export default class ComponentNavMenu extends React.PureComponent<Props> {
   renderAdministration() {
     const { branch } = this.props;
 
-    if (!this.getConfiguration().showSettings || (branch && isShortLivingBranch(branch))) {
+    if (!this.getConfiguration().showSettings || (branch && !branch.isMain)) {
       return null;
     }
 
index 60d527b6a42effaccac8ff9de4b17ca6d78f7046..4c3676fd64f4bba0adaf48801f96468ca4be0e79 100644 (file)
@@ -739,25 +739,6 @@ exports[`should work for long-living branches 1`] = `
       project_activity.page
     </Link>
   </li>
-  <li>
-    <Link
-      className="is-admin"
-      id="component-navigation-admin"
-      onlyActiveOnIndex={false}
-      style={Object {}}
-      to={
-        Object {
-          "pathname": "/project/settings",
-          "query": Object {
-            "branch": "release",
-            "id": "foo",
-          },
-        }
-      }
-    >
-      branches.branch_settings
-    </Link>
-  </li>
 </NavBarTabs>
 `;
 
index cc8717235f3f083584971ed1645b050aeaf2e262..8bf75ae6e04d31990c1edff3d806466944042f30 100644 (file)
@@ -23,6 +23,7 @@ export enum BranchType {
 }
 
 export interface MainBranch {
+  analysisDate?: string;
   isMain: true;
   name: string;
   status?: {
@@ -31,6 +32,7 @@ export interface MainBranch {
 }
 
 export interface LongLivingBranch {
+  analysisDate?: string;
   isMain: false;
   name: string;
   status?: {
@@ -40,6 +42,7 @@ export interface LongLivingBranch {
 }
 
 export interface ShortLivingBranch {
+  analysisDate?: string;
   isMain: false;
   isOrphan?: true;
   mergeBranch: string;
index b56f0a4e917f02445d117386a1ed19717e677bed..832d3050075d7f7f8ca022adfe15c72b95f96d48 100644 (file)
@@ -24,6 +24,7 @@ import { translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure } from '../../../helpers/measures';
 import RemoveMemberForm from './forms/RemoveMemberForm';
 import ManageMemberGroupsForm from './forms/ManageMemberGroupsForm';
+import SettingsIcon from '../../../components/icons-components/SettingsIcon';
 /*:: import type { Member } from '../../../store/organizationsMembers/actions'; */
 /*:: import type { Organization, OrgGroup } from '../../../store/organizations/duck'; */
 
@@ -67,7 +68,7 @@ export default class MembersListItem extends React.PureComponent {
               <button
                 className="dropdown-toggle little-spacer-right button-compact"
                 data-toggle="dropdown">
-                <i className="icon-settings" /> <i className="icon-dropdown" />
+                <SettingsIcon style={{ marginTop: 4 }} /> <i className="icon-dropdown" />
               </button>
               <ul className="dropdown-menu dropdown-menu-right">
                 <li>
index 64b08bff9969175563abeae0a7671bfa55e91b11..0bfde40983a1eadba42272e05632118f777dee96 100644 (file)
@@ -38,8 +38,12 @@ exports[`should groups at 0 if the groupCount field is not defined (just added u
         className="dropdown-toggle little-spacer-right button-compact"
         data-toggle="dropdown"
       >
-        <i
-          className="icon-settings"
+        <SettingsIcon
+          style={
+            Object {
+              "marginTop": 4,
+            }
+          }
         />
          
         <i
@@ -159,8 +163,12 @@ exports[`should render actions and groups for admin 1`] = `
         className="dropdown-toggle little-spacer-right button-compact"
         data-toggle="dropdown"
       >
-        <i
-          className="icon-settings"
+        <SettingsIcon
+          style={
+            Object {
+              "marginTop": 4,
+            }
+          }
         />
          
         <i
index 8b0ee390d575db8ab5b37c9c3269d6c7aa6dd431..b9ed0422bb7a86e798641caed71f756f20dae185 100644 (file)
@@ -24,6 +24,7 @@ import Events from './Events';
 import AddEventForm from './forms/AddEventForm';
 import RemoveAnalysisForm from './forms/RemoveAnalysisForm';
 import TimeTooltipFormatter from '../../../components/intl/TimeTooltipFormatter';
+import SettingsIcon from '../../../components/icons-components/SettingsIcon';
 import { translate } from '../../../helpers/l10n';
 /*:: import type { Analysis } from '../types'; */
 
@@ -76,7 +77,7 @@ export default class ProjectActivityAnalysis extends React.PureComponent {
                 className="js-analysis-actions button-small button-compact dropdown-toggle"
                 data-toggle="dropdown"
                 onClick={this.stopPropagation}>
-                <i className="icon-settings" /> <i className="icon-dropdown" />
+                <SettingsIcon size={12} style={{ marginTop: 3 }} /> <i className="icon-dropdown" />
               </button>
               <ul className="dropdown-menu dropdown-menu-right">
                 {!hasVersion &&
index 02a8e20365fac80aa95ece1584182bbe62a3a8b7..6336d24397f1d6c86067503aa823d65e1aa144b8 100644 (file)
  */
 import * as React from 'react';
 import BranchRow from './BranchRow';
+import LongBranchesPattern from './LongBranchesPattern';
 import { Branch } from '../../../app/types';
 import { sortBranchesAsTree } from '../../../helpers/branches';
 import { translate } from '../../../helpers/l10n';
+import { getValues } from '../../../api/settings';
+import { FormattedMessage } from 'react-intl';
+import { formatMeasure } from '../../../helpers/measures';
+import { Link } from 'react-router';
 
 interface Props {
   branches: Branch[];
+  canAdmin?: boolean;
   component: { key: string };
   onBranchesChange: () => void;
 }
 
-export default function App({ branches, component, onBranchesChange }: Props) {
-  return (
-    <div className="page page-limited">
-      <header className="page-header">
-        <h1 className="page-title">{translate('project_branches.page')}</h1>
-      </header>
-
-      <table className="data zebra zebra-hover">
-        <thead>
-          <tr>
-            <th>{translate('branch')}</th>
-            <th className="text-right">{translate('status')}</th>
-            <th className="text-right">{translate('actions')}</th>
-          </tr>
-        </thead>
-        <tbody>
-          {sortBranchesAsTree(branches).map(branch => (
-            <BranchRow
-              branch={branch}
-              component={component.key}
-              key={branch.name}
-              onChange={onBranchesChange}
-            />
-          ))}
-        </tbody>
-      </table>
-    </div>
-  );
+interface State {
+  branchLifeTime?: string;
+  loading: boolean;
+}
+
+const BRANCH_LIFETIME_SETTING = 'sonar.dbcleaner.daysBeforeDeletingInactiveShortLivingBranches';
+
+export default class App extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { loading: true };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchPurgeSetting();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchPurgeSetting() {
+    this.setState({ loading: true });
+    getValues(BRANCH_LIFETIME_SETTING).then(
+      settings => {
+        if (this.mounted) {
+          this.setState({
+            loading: false,
+            branchLifeTime: settings.length > 0 ? settings[0].value : undefined
+          });
+        }
+      },
+      () => {
+        this.setState({ loading: false });
+      }
+    );
+  }
+
+  renderBranchLifeTime() {
+    const { branchLifeTime } = this.state;
+    if (!branchLifeTime) {
+      return null;
+    }
+
+    const messageKey = this.props.canAdmin
+      ? 'project_branches.page.life_time.admin'
+      : 'project_branches.page.life_time';
+
+    return (
+      <p className="page-description">
+        <FormattedMessage
+          defaultMessage={translate(messageKey)}
+          id={messageKey}
+          values={{
+            days: formatMeasure(this.state.branchLifeTime, 'INT'),
+            settings: <Link to="/admin/settings">{translate('settings.page')}</Link>
+          }}
+        />
+      </p>
+    );
+  }
+
+  render() {
+    const { branches, component, onBranchesChange } = this.props;
+
+    if (this.state.loading) {
+      return (
+        <div className="page page-limited">
+          <header className="page-header">
+            <h1 className="page-title">{translate('project_branches.page')}</h1>
+          </header>
+          <i className="spinner" />
+        </div>
+      );
+    }
+
+    return (
+      <div className="page page-limited">
+        <header className="page-header">
+          <h1 className="page-title">{translate('project_branches.page')}</h1>
+          <LongBranchesPattern project={component.key} />
+          <p className="page-description">{translate('project_branches.page.description')}</p>
+          {this.renderBranchLifeTime()}
+        </header>
+
+        <table className="data zebra zebra-hover">
+          <thead>
+            <tr>
+              <th>{translate('branch')}</th>
+              <th className="thin nowrap text-right">{translate('status')}</th>
+              <th className="thin nowrap text-right">
+                {translate('project_history.last_snapshot')}
+              </th>
+              <th className="thin nowrap text-right">{translate('actions')}</th>
+            </tr>
+          </thead>
+          <tbody>
+            {sortBranchesAsTree(branches).map(branch => (
+              <BranchRow
+                branch={branch}
+                component={component.key}
+                key={branch.name}
+                onChange={onBranchesChange}
+              />
+            ))}
+          </tbody>
+        </table>
+      </div>
+    );
+  }
 }
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/AppContainer.ts b/server/sonar-web/src/main/js/apps/projectBranches/components/AppContainer.ts
new file mode 100644 (file)
index 0000000..9e89531
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * 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 { connect } from 'react-redux';
+import App from './App';
+import { getAppState } from '../../../store/rootReducer';
+
+const mapStateToProps = (state: any) => ({
+  canAdmin: getAppState(state).canAdmin
+});
+
+export default connect<any, any, any>(mapStateToProps)(App);
index e163481d7596a6f93bda3d1ead843f55e0bf77e0..3cbc25df9a4ee16674a9b58a949f495381286d47 100644 (file)
@@ -21,14 +21,14 @@ import * as React from 'react';
 import { Branch } from '../../../app/types';
 import * as classNames from 'classnames';
 import DeleteBranchModal from './DeleteBranchModal';
+import LeakPeriodForm from './LeakPeriodForm';
 import BranchStatus from '../../../components/common/BranchStatus';
 import BranchIcon from '../../../components/icons-components/BranchIcon';
-import { isShortLivingBranch } from '../../../helpers/branches';
-import ChangeIcon from '../../../components/icons-components/ChangeIcon';
-import DeleteIcon from '../../../components/icons-components/DeleteIcon';
+import { isShortLivingBranch, isLongLivingBranch } from '../../../helpers/branches';
 import { translate } from '../../../helpers/l10n';
-import Tooltip from '../../../components/controls/Tooltip';
 import RenameBranchModal from './RenameBranchModal';
+import DateFromNow from '../../../components/intl/DateFromNow';
+import SettingsIcon from '../../../components/icons-components/SettingsIcon';
 
 interface Props {
   branch: Branch;
@@ -37,13 +37,14 @@ interface Props {
 }
 
 interface State {
+  changingLeak: boolean;
   deleting: boolean;
   renaming: boolean;
 }
 
 export default class BranchRow extends React.PureComponent<Props, State> {
   mounted: boolean;
-  state: State = { deleting: false, renaming: false };
+  state: State = { changingLeak: false, deleting: false, renaming: false };
 
   componentDidMount() {
     this.mounted = true;
@@ -80,6 +81,18 @@ export default class BranchRow extends React.PureComponent<Props, State> {
     this.setState({ renaming: false });
   };
 
+  handleChangeLeakClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ changingLeak: true });
+  };
+
+  handleChangingLeakStop = () => {
+    if (this.mounted) {
+      this.setState({ changingLeak: false });
+    }
+  };
+
   render() {
     const { branch, component } = this.props;
 
@@ -101,19 +114,47 @@ export default class BranchRow extends React.PureComponent<Props, State> {
           <BranchStatus branch={branch} />
         </td>
         <td className="thin nowrap text-right">
-          {branch.isMain ? (
-            <Tooltip overlay={translate('branches.rename')}>
-              <a className="js-rename link-no-underline" href="#" onClick={this.handleRenameClick}>
-                <ChangeIcon />
-              </a>
-            </Tooltip>
-          ) : (
-            <Tooltip overlay={translate('branches.delete')}>
-              <a className="js-delete link-no-underline" href="#" onClick={this.handleDeleteClick}>
-                <DeleteIcon />
-              </a>
-            </Tooltip>
-          )}
+          {branch.analysisDate && <DateFromNow date={branch.analysisDate} />}
+        </td>
+        <td className="thin nowrap text-right">
+          <div className="dropdown big-spacer-left">
+            <button
+              className="dropdown-toggle little-spacer-right button-compact"
+              data-toggle="dropdown">
+              <SettingsIcon style={{ marginTop: 4 }} /> <i className="icon-dropdown" />
+            </button>
+            <ul className="dropdown-menu dropdown-menu-right">
+              {isLongLivingBranch(branch) && (
+                <li>
+                  <a
+                    className="js-change-leak-period link-no-underline"
+                    href="#"
+                    onClick={this.handleChangeLeakClick}>
+                    {translate('branches.set_leak_period')}
+                  </a>
+                </li>
+              )}
+              {branch.isMain ? (
+                <li>
+                  <a
+                    className="js-rename link-no-underline"
+                    href="#"
+                    onClick={this.handleRenameClick}>
+                    {translate('branches.rename')}
+                  </a>
+                </li>
+              ) : (
+                <li>
+                  <a
+                    className="js-delete link-no-underline"
+                    href="#"
+                    onClick={this.handleDeleteClick}>
+                    {translate('branches.delete')}
+                  </a>
+                </li>
+              )}
+            </ul>
+          </div>
         </td>
 
         {this.state.deleting && (
@@ -133,6 +174,14 @@ export default class BranchRow extends React.PureComponent<Props, State> {
             onRename={this.handleChange}
           />
         )}
+
+        {this.state.changingLeak && (
+          <LeakPeriodForm
+            branch={branch.name}
+            onClose={this.handleChangingLeakStop}
+            project={component}
+          />
+        )}
       </tr>
     );
   }
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/LeakPeriodForm.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/LeakPeriodForm.tsx
new file mode 100644 (file)
index 0000000..26d0c82
--- /dev/null
@@ -0,0 +1,106 @@
+/*
+ * 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 Modal from 'react-modal';
+import SettingForm from './SettingForm';
+import { translate } from '../../../helpers/l10n';
+import { getValues, SettingValue } from '../../../api/settings';
+
+interface Props {
+  branch: string;
+  onClose: () => void;
+  project: string;
+}
+
+interface State {
+  loading: boolean;
+  setting?: SettingValue;
+  submitting: boolean;
+  value?: string;
+}
+
+const LEAK_PERIOD = 'sonar.leak.period';
+
+export default class LeakPeriodForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { loading: true, submitting: false };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchSetting();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchSetting() {
+    this.setState({ loading: true });
+    getValues(LEAK_PERIOD, this.props.project, this.props.branch).then(
+      settings => {
+        if (this.mounted) {
+          this.setState({ loading: false, setting: settings[0] });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  }
+
+  handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    this.props.onClose();
+  };
+
+  render() {
+    const { setting } = this.state;
+    const header = translate('branches.set_leak_period');
+
+    return (
+      <Modal
+        isOpen={true}
+        contentLabel={header}
+        className="modal"
+        overlayClassName="modal-overlay"
+        onRequestClose={this.props.onClose}>
+        <header className="modal-head">
+          <h2>{header}</h2>
+        </header>
+        {this.state.loading && (
+          <div className="modal-body">
+            <i className="spinner" />
+          </div>
+        )}
+        {setting && (
+          <SettingForm
+            branch={this.props.branch}
+            onChange={this.props.onClose}
+            onClose={this.props.onClose}
+            project={this.props.project}
+            setting={setting}
+          />
+        )}
+      </Modal>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPattern.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPattern.tsx
new file mode 100644 (file)
index 0000000..da02668
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * 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 LongBranchesPatternForm from './LongBranchesPatternForm';
+import { getValues, SettingValue } from '../../../api/settings';
+import ChangeIcon from '../../../components/icons-components/ChangeIcon';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  project: string;
+}
+
+interface State {
+  formOpen: boolean;
+  setting?: SettingValue;
+}
+
+export const LONG_BRANCH_PATTERN = 'sonar.branch.longLivedBranches.regex';
+
+export default class LongBranchesPattern extends React.PureComponent<Props, State> {
+  mounted: boolean;
+  state: State = { formOpen: false };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchSetting();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchSetting() {
+    return getValues(LONG_BRANCH_PATTERN, this.props.project).then(
+      settings => {
+        if (this.mounted) {
+          this.setState({ setting: settings[0] });
+        }
+      },
+      () => {}
+    );
+  }
+
+  closeForm = () => {
+    if (this.mounted) {
+      this.setState({ formOpen: false });
+    }
+  };
+
+  handleChangeClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ formOpen: true });
+  };
+
+  handleChange = () => {
+    if (this.mounted) {
+      this.fetchSetting().then(this.closeForm, this.closeForm);
+    }
+  };
+
+  render() {
+    const { setting } = this.state;
+
+    if (!setting) {
+      return null;
+    }
+
+    return (
+      <div className="pull-right text-right">
+        {translate('branches.long_living_branches_pattern')}
+        {': '}
+        <strong>{setting.value}</strong>
+        <a
+          className="display-inline-block spacer-left link-no-underline"
+          href="#"
+          onClick={this.handleChangeClick}>
+          <ChangeIcon />
+        </a>
+        {this.state.formOpen && (
+          <LongBranchesPatternForm
+            onClose={this.closeForm}
+            onChange={this.handleChange}
+            project={this.props.project}
+            setting={setting}
+          />
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPatternForm.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/LongBranchesPatternForm.tsx
new file mode 100644 (file)
index 0000000..c773b3c
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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 Modal from 'react-modal';
+import SettingForm from './SettingForm';
+import { translate } from '../../../helpers/l10n';
+import { SettingValue } from '../../../api/settings';
+
+interface Props {
+  onChange: () => void;
+  onClose: () => void;
+  project: string;
+  setting: SettingValue;
+}
+
+export default function LongBranchesPatternForm(props: Props) {
+  const header = translate('branches.detection_of_long_living_branches');
+
+  return (
+    <Modal
+      isOpen={true}
+      contentLabel={header}
+      className="modal"
+      overlayClassName="modal-overlay"
+      onRequestClose={props.onClose}>
+      <header className="modal-head">
+        <h2>{header}</h2>
+      </header>
+      <SettingForm {...props} />
+    </Modal>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/SettingForm.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/SettingForm.tsx
new file mode 100644 (file)
index 0000000..41e8c9d
--- /dev/null
@@ -0,0 +1,150 @@
+/*
+ * 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 { SettingValue, setSimpleSettingValue, resetSettingValue } from '../../../api/settings';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  branch?: string;
+  onClose: () => void;
+  onChange: () => void;
+  project: string;
+  setting: SettingValue;
+}
+
+interface State {
+  submitting: boolean;
+  value?: string;
+}
+
+export default class SettingForm extends React.PureComponent<Props, State> {
+  mounted: boolean;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = { submitting: false, value: props.setting.value };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    const { value } = this.state;
+    if (!value) {
+      return;
+    }
+
+    this.setState({ submitting: true });
+    setSimpleSettingValue({
+      branch: this.props.branch,
+      component: this.props.project,
+      key: this.props.setting.key,
+      value
+    }).then(this.props.onChange, () => {
+      if (this.mounted) {
+        this.setState({ submitting: false });
+      }
+    });
+  };
+
+  handleValueChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+    this.setState({ value: event.currentTarget.value });
+  };
+
+  handleResetClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.setState({ submitting: true });
+    resetSettingValue(this.props.setting.key, this.props.project, this.props.branch).then(
+      this.props.onChange,
+      () => {
+        if (this.mounted) {
+          this.setState({ submitting: false });
+        }
+      }
+    );
+  };
+
+  handleCancelClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    this.props.onClose();
+  };
+
+  render() {
+    const { setting } = this.props;
+    const submitDisabled = this.state.submitting || this.state.value === setting.value;
+
+    return (
+      <form onSubmit={this.handleSubmit}>
+        <div className="modal-body">
+          <div
+            className="big-spacer-bottom markdown"
+            dangerouslySetInnerHTML={{ __html: translate(`property.${setting.key}.description`) }}
+          />
+          <div className="big-spacer-bottom">
+            <input
+              autoFocus={true}
+              className="input-super-large"
+              onChange={this.handleValueChange}
+              required={true}
+              type="text"
+              value={this.state.value}
+            />
+            {setting.inherited && (
+              <div className="note spacer-top">{translate('settings._default')}</div>
+            )}
+            {!setting.inherited &&
+            setting.parentValue && (
+              <div className="note spacer-top">
+                {translateWithParameters('settings.default_x', setting.parentValue)}
+              </div>
+            )}
+          </div>
+        </div>
+        <footer className="modal-foot">
+          {!setting.inherited &&
+          setting.parentValue && (
+            <button
+              className="pull-left"
+              disabled={this.state.submitting}
+              onClick={this.handleResetClick}
+              type="reset">
+              {translate('reset_to_default')}
+            </button>
+          )}
+          {this.state.submitting && <i className="spinner spacer-right" />}
+          <button disabled={submitDisabled} type="submit">
+            {translate('save')}
+          </button>
+          <a href="#" onClick={this.handleCancelClick}>
+            {translate('cancel')}
+          </a>
+        </footer>
+      </form>
+    );
+  }
+}
index 4288105f79a389114929f4b100a9583ba5f863b3..9ffafb34d725d863615758e72a0c4d63a17eee7d 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+jest.mock('../../../../api/settings', () => ({
+  getValues: jest.fn(() => Promise.resolve([]))
+}));
+
 import * as React from 'react';
-import { shallow } from 'enzyme';
+import { mount, shallow } from 'enzyme';
 import App from '../App';
 import { Branch, BranchType } from '../../../../app/types';
 
+const getValues = require('../../../../api/settings').getValues as jest.Mock<any>;
+
+beforeEach(() => {
+  getValues.mockClear();
+});
+
 it('renders sorted list of branches', () => {
   const branches: Branch[] = [
     { isMain: true, name: 'master' },
     { isMain: false, name: 'branch-1.0', type: BranchType.LONG },
     { isMain: false, name: 'branch-1.0', mergeBranch: 'master', type: BranchType.SHORT }
   ];
-  expect(
-    shallow(<App branches={branches} component={{ key: 'foo' }} onBranchesChange={jest.fn()} />)
-  ).toMatchSnapshot();
+  const wrapper = shallow(
+    <App branches={branches} component={{ key: 'foo' }} onBranchesChange={jest.fn()} />
+  );
+  wrapper.setState({ branchLifeTime: '100', loading: false });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('fetches branch life time setting on mount', () => {
+  mount(<App branches={[]} component={{ key: 'foo' }} onBranchesChange={jest.fn()} />);
+  expect(getValues).toBeCalledWith('sonar.dbcleaner.daysBeforeDeletingInactiveShortLivingBranches');
 });
index 4edc3ce70d6aed8155b07042bacff85f26b20b72..667d377733f3d8c2f0ee856478f73250c2aa1823 100644 (file)
@@ -26,6 +26,7 @@ import { click } from '../../../../helpers/testUtils';
 const mainBranch: MainBranch = { isMain: true, name: 'master' };
 
 const shortBranch: ShortLivingBranch = {
+  analysisDate: '2017-09-27T00:05:19+0000',
   isMain: false,
   name: 'feature',
   mergeBranch: 'foo',
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPattern-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPattern-test.tsx
new file mode 100644 (file)
index 0000000..ff142d2
--- /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.
+ */
+jest.mock('../../../../api/settings', () => ({
+  getValues: jest.fn(() => Promise.resolve([]))
+}));
+
+import * as React from 'react';
+import { mount, shallow } from 'enzyme';
+import LongBranchesPattern from '../LongBranchesPattern';
+import { click } from '../../../../helpers/testUtils';
+
+const getValues = require('../../../../api/settings').getValues as jest.Mock<any>;
+
+beforeEach(() => {
+  getValues.mockClear();
+});
+
+it('renders', () => {
+  const wrapper = shallow(<LongBranchesPattern project="project" />);
+  wrapper.setState({ loading: false, setting: { value: 'release-.*' } });
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('opens form', () => {
+  const wrapper = shallow(<LongBranchesPattern project="project" />);
+  (wrapper.instance() as LongBranchesPattern) .mounted = true;
+  wrapper.setState({ loading: false, setting: { value: 'release-.*' } });
+
+  click(wrapper.find('a'));
+  expect(wrapper.find('LongBranchesPatternForm').exists()).toBeTruthy();
+
+  wrapper.find('LongBranchesPatternForm').prop<Function>('onClose')();
+  expect(wrapper.find('LongBranchesPatternForm').exists()).toBeFalsy();
+});
+
+it('fetches setting value on mount', () => {
+  mount(<LongBranchesPattern project="project" />);
+  expect(getValues).lastCalledWith('sonar.branch.longLivedBranches.regex', 'project');
+});
+
+it('fetches new setting value after change', () => {
+  const wrapper = mount(<LongBranchesPattern project="project" />);
+  expect(getValues.mock.calls).toHaveLength(1);
+
+  (wrapper.instance() as LongBranchesPattern).handleChange();
+  expect(getValues.mock.calls).toHaveLength(2);
+});
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPatternForm-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/LongBranchesPatternForm-test.tsx
new file mode 100644 (file)
index 0000000..d70e0a4
--- /dev/null
@@ -0,0 +1,35 @@
+/*
+ * 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 LongBranchesPatternForm from '../LongBranchesPatternForm';
+
+it('renders', () => {
+  expect(
+    shallow(
+      <LongBranchesPatternForm
+        onChange={jest.fn()}
+        onClose={jest.fn()}
+        project="project"
+        setting={{ key: 'foo', value: 'bar' }}
+      />
+    )
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/SettingForm-test.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/SettingForm-test.tsx
new file mode 100644 (file)
index 0000000..f74831c
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * 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.
+ */
+jest.mock('../../../../api/settings', () => ({
+  setSimpleSettingValue: jest.fn(() => Promise.resolve()),
+  resetSettingValue: jest.fn(() => Promise.resolve())
+}));
+
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import SettingForm from '../SettingForm';
+import { change, submit, click } from '../../../../helpers/testUtils';
+
+const setSimpleSettingValue = require('../../../../api/settings')
+  .setSimpleSettingValue as jest.Mock<any>;
+
+const resetSettingValue = require('../../../../api/settings').resetSettingValue as jest.Mock<any>;
+
+beforeEach(() => {
+  setSimpleSettingValue.mockClear();
+  resetSettingValue.mockClear();
+});
+
+it('changes value', async () => {
+  const onChange = jest.fn();
+  const wrapper = shallow(
+    <SettingForm
+      onChange={onChange}
+      onClose={jest.fn()}
+      project="project"
+      setting={{ inherited: true, key: 'foo', value: 'release-.*' }}
+    />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  change(wrapper.find('input'), 'branch-.*');
+  submit(wrapper.find('form'));
+  expect(setSimpleSettingValue).toBeCalledWith({
+    branch: undefined,
+    component: 'project',
+    key: 'foo',
+    value: 'branch-.*'
+  });
+
+  await new Promise(setImmediate);
+  expect(onChange).toBeCalled();
+});
+
+it('resets value', async () => {
+  const onChange = jest.fn();
+  const wrapper = shallow(
+    <SettingForm
+      onChange={onChange}
+      onClose={jest.fn()}
+      project="project"
+      setting={{ inherited: false, key: 'foo', parentValue: 'branch-.*', value: 'release-.*' }}
+    />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  click(wrapper.find('button[type="reset"]'));
+  expect(resetSettingValue).toBeCalledWith('foo', 'project', undefined);
+
+  await new Promise(setImmediate);
+  expect(onChange).toBeCalled();
+});
index 6f983e33df894067b747adb06e4b4de46a27697f..349913a66e3dbd0241e109215c4bada0cdd4928f 100644 (file)
@@ -12,6 +12,34 @@ exports[`renders sorted list of branches 1`] = `
     >
       project_branches.page
     </h1>
+    <LongBranchesPattern
+      project="foo"
+    />
+    <p
+      className="page-description"
+    >
+      project_branches.page.description
+    </p>
+    <p
+      className="page-description"
+    >
+      <FormattedMessage
+        defaultMessage="project_branches.page.life_time"
+        id="project_branches.page.life_time"
+        values={
+          Object {
+            "days": "100",
+            "settings": <Link
+              onlyActiveOnIndex={false}
+              style={Object {}}
+              to="/admin/settings"
+          >
+              settings.page
+          </Link>,
+          }
+        }
+      />
+    </p>
   </header>
   <table
     className="data zebra zebra-hover"
@@ -22,12 +50,17 @@ exports[`renders sorted list of branches 1`] = `
           branch
         </th>
         <th
-          className="text-right"
+          className="thin nowrap text-right"
         >
           status
         </th>
         <th
-          className="text-right"
+          className="thin nowrap text-right"
+        >
+          project_history.last_snapshot
+        </th>
+        <th
+          className="thin nowrap text-right"
         >
           actions
         </th>
index ea135765ece302b5182d7c2b563a13666df5fab2..febb99812b8eb45b6b92e503194e0b254f5a8dfe 100644 (file)
@@ -31,21 +31,45 @@ exports[`renders main branch 1`] = `
       }
     />
   </td>
+  <td
+    className="thin nowrap text-right"
+  />
   <td
     className="thin nowrap text-right"
   >
-    <Tooltip
-      overlay="branches.rename"
-      placement="bottom"
+    <div
+      className="dropdown big-spacer-left"
     >
-      <a
-        className="js-rename link-no-underline"
-        href="#"
-        onClick={[Function]}
+      <button
+        className="dropdown-toggle little-spacer-right button-compact"
+        data-toggle="dropdown"
+      >
+        <SettingsIcon
+          style={
+            Object {
+              "marginTop": 4,
+            }
+          }
+        />
+         
+        <i
+          className="icon-dropdown"
+        />
+      </button>
+      <ul
+        className="dropdown-menu dropdown-menu-right"
       >
-        <ChangeIcon />
-      </a>
-    </Tooltip>
+        <li>
+          <a
+            className="js-rename link-no-underline"
+            href="#"
+            onClick={[Function]}
+          >
+            branches.rename
+          </a>
+        </li>
+      </ul>
+    </div>
   </td>
 </tr>
 `;
@@ -56,6 +80,7 @@ exports[`renders short-living branch 1`] = `
     <BranchIcon
       branch={
         Object {
+          "analysisDate": "2017-09-27T00:05:19+0000",
           "isMain": false,
           "mergeBranch": "foo",
           "name": "feature",
@@ -72,6 +97,7 @@ exports[`renders short-living branch 1`] = `
     <BranchStatus
       branch={
         Object {
+          "analysisDate": "2017-09-27T00:05:19+0000",
           "isMain": false,
           "mergeBranch": "foo",
           "name": "feature",
@@ -83,18 +109,46 @@ exports[`renders short-living branch 1`] = `
   <td
     className="thin nowrap text-right"
   >
-    <Tooltip
-      overlay="branches.delete"
-      placement="bottom"
+    <DateFromNow
+      date="2017-09-27T00:05:19+0000"
+    />
+  </td>
+  <td
+    className="thin nowrap text-right"
+  >
+    <div
+      className="dropdown big-spacer-left"
     >
-      <a
-        className="js-delete link-no-underline"
-        href="#"
-        onClick={[Function]}
+      <button
+        className="dropdown-toggle little-spacer-right button-compact"
+        data-toggle="dropdown"
+      >
+        <SettingsIcon
+          style={
+            Object {
+              "marginTop": 4,
+            }
+          }
+        />
+         
+        <i
+          className="icon-dropdown"
+        />
+      </button>
+      <ul
+        className="dropdown-menu dropdown-menu-right"
       >
-        <DeleteIcon />
-      </a>
-    </Tooltip>
+        <li>
+          <a
+            className="js-delete link-no-underline"
+            href="#"
+            onClick={[Function]}
+          >
+            branches.delete
+          </a>
+        </li>
+      </ul>
+    </div>
   </td>
 </tr>
 `;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPattern-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPattern-test.tsx.snap
new file mode 100644 (file)
index 0000000..312bdd3
--- /dev/null
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+  className="pull-right text-right"
+>
+  branches.long_living_branches_pattern
+  : 
+  <strong>
+    release-.*
+  </strong>
+  <a
+    className="display-inline-block spacer-left link-no-underline"
+    href="#"
+    onClick={[Function]}
+  >
+    <ChangeIcon />
+  </a>
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPatternForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/LongBranchesPatternForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..d1c5ef0
--- /dev/null
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Modal
+  ariaHideApp={true}
+  bodyOpenClassName="ReactModal__Body--open"
+  className="modal"
+  closeTimeoutMS={0}
+  contentLabel="branches.detection_of_long_living_branches"
+  isOpen={true}
+  onRequestClose={[Function]}
+  overlayClassName="modal-overlay"
+  parentSelector={[Function]}
+  portalClassName="ReactModalPortal"
+  shouldCloseOnOverlayClick={true}
+>
+  <header
+    className="modal-head"
+  >
+    <h2>
+      branches.detection_of_long_living_branches
+    </h2>
+  </header>
+  <SettingForm
+    onChange={[Function]}
+    onClose={[Function]}
+    project="project"
+    setting={
+      Object {
+        "key": "foo",
+        "value": "bar",
+      }
+    }
+  />
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/SettingForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectBranches/components/__tests__/__snapshots__/SettingForm-test.tsx.snap
new file mode 100644 (file)
index 0000000..bec3f23
--- /dev/null
@@ -0,0 +1,113 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`changes value 1`] = `
+<form
+  onSubmit={[Function]}
+>
+  <div
+    className="modal-body"
+  >
+    <div
+      className="big-spacer-bottom markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "property.foo.description",
+        }
+      }
+    />
+    <div
+      className="big-spacer-bottom"
+    >
+      <input
+        autoFocus={true}
+        className="input-super-large"
+        onChange={[Function]}
+        required={true}
+        type="text"
+        value="release-.*"
+      />
+      <div
+        className="note spacer-top"
+      >
+        settings._default
+      </div>
+    </div>
+  </div>
+  <footer
+    className="modal-foot"
+  >
+    <button
+      disabled={true}
+      type="submit"
+    >
+      save
+    </button>
+    <a
+      href="#"
+      onClick={[Function]}
+    >
+      cancel
+    </a>
+  </footer>
+</form>
+`;
+
+exports[`resets value 1`] = `
+<form
+  onSubmit={[Function]}
+>
+  <div
+    className="modal-body"
+  >
+    <div
+      className="big-spacer-bottom markdown"
+      dangerouslySetInnerHTML={
+        Object {
+          "__html": "property.foo.description",
+        }
+      }
+    />
+    <div
+      className="big-spacer-bottom"
+    >
+      <input
+        autoFocus={true}
+        className="input-super-large"
+        onChange={[Function]}
+        required={true}
+        type="text"
+        value="release-.*"
+      />
+      <div
+        className="note spacer-top"
+      >
+        settings.default_x.branch-.*
+      </div>
+    </div>
+  </div>
+  <footer
+    className="modal-foot"
+  >
+    <button
+      className="pull-left"
+      disabled={false}
+      onClick={[Function]}
+      type="reset"
+    >
+      reset_to_default
+    </button>
+    <button
+      disabled={true}
+      type="submit"
+    >
+      save
+    </button>
+    <a
+      href="#"
+      onClick={[Function]}
+    >
+      cancel
+    </a>
+  </footer>
+</form>
+`;
index 520805ebac5907cb1aaa81c72fa9a9f01b8d7d75..8cc627aeca73e0c008a1dfaa255309c08825341b 100644 (file)
@@ -22,7 +22,9 @@ 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 as any).default }));
+      import('./components/AppContainer').then(i =>
+        callback(null, { component: (i as any).default })
+      );
     }
   }
 ];
index 866830fbb69abcec875437ccfbcdc6adad891aac..7df88bb9fb79aa44976eb4c74acb3a932b27b9bf 100644 (file)
 // @flow
 import React from 'react';
 import Helmet from 'react-helmet';
-import { Link } from 'react-router';
-import { FormattedMessage } from 'react-intl';
 import PageHeader from './PageHeader';
 import CategoryDefinitionsList from './CategoryDefinitionsList';
 import AllCategoriesList from './AllCategoriesList';
 import WildcardsHelp from './WildcardsHelp';
-import { getBranchName } from '../../../helpers/branches';
 import { translate } from '../../../helpers/l10n';
 import '../styles.css';
 
 /*::
 type Props = {
-  branch?: {},
   component?: { key: string },
   defaultCategory: ?string,
-  fetchSettings(componentKey: ?string, branch?: string): Promise<*>,
+  fetchSettings(componentKey: ?string): Promise<*>,
   location: { query: {} }
 };
 */
@@ -56,15 +52,13 @@ export default class App extends React.PureComponent {
       html.classList.add('dashboard-page');
     }
     const componentKey = this.props.component ? this.props.component.key : null;
-    const branch = this.props.branch && getBranchName(this.props.branch);
-    this.props.fetchSettings(componentKey, branch).then(() => this.setState({ loaded: true }));
+    this.props.fetchSettings(componentKey).then(() => this.setState({ loaded: true }));
   }
 
   componentDidUpdate(prevProps /*: Props*/) {
     if (prevProps.component !== this.props.component) {
       const componentKey = this.props.component ? this.props.component.key : null;
-      const branch = this.props.branch && getBranchName(this.props.branch);
-      this.props.fetchSettings(componentKey, branch);
+      this.props.fetchSettings(componentKey);
     }
   }
 
@@ -83,51 +77,22 @@ export default class App extends React.PureComponent {
     const { query } = this.props.location;
     const selectedCategory = query.category || this.props.defaultCategory;
 
-    const branchName = this.props.branch && getBranchName(this.props.branch);
-
     return (
       <div id="settings-page" className="page page-limited">
         <Helmet title={translate('settings.page')} />
 
-        {branchName ? (
-          <div className="alert alert-info">
-            <FormattedMessage
-              defaultMessage={translate('branches.settings_hint')}
-              id="branches.settings_hint"
-              values={{
-                link: (
-                  <Link
-                    to={{
-                      pathname: '/project/branches',
-                      query: { id: this.props.component && this.props.component.key }
-                    }}>
-                    {translate('branches.settings_hint_tab')}
-                  </Link>
-                )
-              }}
-            />
-          </div>
-        ) : (
-          <PageHeader branch={branchName} component={this.props.component} />
-        )}
+        <PageHeader component={this.props.component} />
+
         <div className="side-tabs-layout settings-layout">
-          {branchName == null && (
-            <div className="side-tabs-side">
-              <AllCategoriesList
-                branch={branchName}
-                component={this.props.component}
-                selectedCategory={selectedCategory}
-                defaultCategory={this.props.defaultCategory}
-              />
-            </div>
-          )}
-          <div className="side-tabs-main">
-            <CategoryDefinitionsList
-              branch={branchName}
+          <div className="side-tabs-side">
+            <AllCategoriesList
               component={this.props.component}
-              category={selectedCategory}
+              selectedCategory={selectedCategory}
+              defaultCategory={this.props.defaultCategory}
             />
-
+          </div>
+          <div className="side-tabs-main">
+            <CategoryDefinitionsList component={this.props.component} category={selectedCategory} />
             {selectedCategory === 'exclusions' && <WildcardsHelp />}
           </div>
         </div>
index 8cbda6596c72fec42e48c15d62ee6a71bd24698e..da25601fd40afd6da33bdfa284803fc29d531c78 100644 (file)
@@ -32,7 +32,6 @@ type Category = {
 
 /*::
 type Props = {
-  branch?: string,
   categories: Category[],
   component?: { key: string },
   defaultCategory: string,
@@ -44,7 +43,7 @@ export default class CategoriesList extends React.PureComponent {
   /*:: rops: Props; */
 
   renderLink(category /*: Category */) {
-    const query /*: Object */ = { branch: this.props.branch };
+    const query /*: Object */ = {};
 
     if (category.key !== this.props.defaultCategory) {
       query.category = category.key.toLowerCase();
index 91eec4f2433d6a034427180e910b2bdb1a6c3a05..fc63e9d89304267bfa8e134ca4d591f6f0dd68b6 100644 (file)
@@ -47,7 +47,6 @@ class Definition extends React.PureComponent {
   /*:: timeout: number; */
 
   static propTypes = {
-    branch: PropTypes.string,
     component: PropTypes.object,
     setting: PropTypes.object.isRequired,
     changedValue: PropTypes.any,
@@ -91,7 +90,7 @@ class Definition extends React.PureComponent {
     const componentKey = this.props.component ? this.props.component.key : null;
     const { definition } = this.props.setting;
     return this.props
-      .resetValue(definition.key, componentKey, this.props.branch)
+      .resetValue(definition.key, componentKey)
       .then(() => {
         this.safeSetState({ success: true });
         this.timeout = setTimeout(() => this.safeSetState({ success: false }), 3000);
@@ -111,7 +110,7 @@ class Definition extends React.PureComponent {
     this.safeSetState({ success: false });
     const componentKey = this.props.component ? this.props.component.key : null;
     this.props
-      .saveValue(this.props.setting.definition.key, componentKey, this.props.branch)
+      .saveValue(this.props.setting.definition.key, componentKey)
       .then(() => {
         this.safeSetState({ success: true });
         this.timeout = setTimeout(() => this.safeSetState({ success: false }), 3000);
index 7cb111b297a0a5b1e237859573c561c40637114c..a04d9aa720339117c29e913b6ed02a5d9012ec17 100644 (file)
@@ -24,7 +24,6 @@ import Definition from './Definition';
 
 export default class DefinitionsList extends React.PureComponent {
   static propTypes = {
-    branch: PropTypes.string,
     component: PropTypes.object,
     settings: PropTypes.array.isRequired
   };
@@ -34,11 +33,7 @@ export default class DefinitionsList extends React.PureComponent {
       <ul className="settings-definitions-list">
         {this.props.settings.map(setting => (
           <li key={setting.definition.key}>
-            <Definition
-              branch={this.props.branch}
-              component={this.props.component}
-              setting={setting}
-            />
+            <Definition component={this.props.component} setting={setting} />
           </li>
         ))}
       </ul>
index d70bb1e1b1398050fa8874557fd741507965ab68..c68594d8bef666d86d1c1d33173500153cc771f9 100644 (file)
@@ -24,7 +24,6 @@ import { translate } from '../../../helpers/l10n';
 
 export default class PageHeader extends React.PureComponent {
   static propTypes = {
-    branch: PropTypes.string,
     component: PropTypes.object
   };
 
index e779f1105c9f95545a5d0da15d0f90ed1adef674..4a08b134fd1b9ea958521cada3e73f5e8a38b799 100644 (file)
@@ -27,7 +27,6 @@ import { getSubCategoryName, getSubCategoryDescription } from '../utils';
 
 export default class SubCategoryDefinitionsList extends React.PureComponent {
   static propTypes = {
-    branch: PropTypes.string,
     component: PropTypes.object,
     settings: PropTypes.array.isRequired
   };
@@ -63,7 +62,6 @@ export default class SubCategoryDefinitionsList extends React.PureComponent {
               />
             )}
             <DefinitionsList
-              branch={this.props.branch}
               component={this.props.component}
               settings={bySubCategory[subCategory.key]}
             />
index 25e75c2b3c3927a1e1d29ad86aecc7f20ac809e2..8c42137cf57f7da865673dcb5ef9671228713beb 100644 (file)
@@ -34,13 +34,19 @@ import { isEmptyValue } from '../utils';
 import { translate } from '../../../helpers/l10n';
 import { getSettingsAppDefinition, getSettingsAppChangedValue } from '../../../store/rootReducer';
 
-export const fetchSettings = (componentKey, branch) => dispatch => {
-  return getDefinitions(componentKey, branch)
+export const fetchSettings = componentKey => dispatch => {
+  return getDefinitions(componentKey)
     .then(definitions => {
-      const withoutLicenses = definitions.filter(definition => definition.type !== 'LICENSE');
-      dispatch(receiveDefinitions(withoutLicenses));
-      const keys = withoutLicenses.map(definition => definition.key).join();
-      return getValues(keys, componentKey, branch);
+      const filtered = definitions
+        .filter(definition => definition.type !== 'LICENSE')
+        // do not display this setting on project level
+        .filter(
+          definition =>
+            componentKey == null || definition.key !== 'sonar.branch.longLivedBranches.regex'
+        );
+      dispatch(receiveDefinitions(filtered));
+      const keys = filtered.map(definition => definition.key).join();
+      return getValues(keys, componentKey);
     })
     .then(settings => {
       dispatch(receiveValues(settings, componentKey));
@@ -49,7 +55,7 @@ export const fetchSettings = (componentKey, branch) => dispatch => {
     .catch(e => parseError(e).then(message => dispatch(addGlobalErrorMessage(message))));
 };
 
-export const saveValue = (key, componentKey, branch) => (dispatch, getState) => {
+export const saveValue = (key, componentKey) => (dispatch, getState) => {
   dispatch(startLoading(key));
 
   const state = getState();
@@ -62,8 +68,8 @@ export const saveValue = (key, componentKey, branch) => (dispatch, getState) =>
     return Promise.reject();
   }
 
-  return setSettingValue(definition, value, componentKey, branch)
-    .then(() => getValues(key, componentKey, branch))
+  return setSettingValue(definition, value, componentKey)
+    .then(() => getValues(key, componentKey))
     .then(values => {
       dispatch(receiveValues(values, componentKey));
       dispatch(cancelChange(key));
@@ -77,11 +83,11 @@ export const saveValue = (key, componentKey, branch) => (dispatch, getState) =>
     });
 };
 
-export const resetValue = (key, componentKey, branch) => dispatch => {
+export const resetValue = (key, componentKey) => dispatch => {
   dispatch(startLoading(key));
 
-  return resetSettingValue(key, componentKey, branch)
-    .then(() => getValues(key, componentKey, branch))
+  return resetSettingValue(key, componentKey)
+    .then(() => getValues(key, componentKey))
     .then(values => {
       if (values.length > 0) {
         dispatch(receiveValues(values, componentKey));
diff --git a/server/sonar-web/src/main/js/components/icons-components/SettingsIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/SettingsIcon.tsx
new file mode 100644 (file)
index 0000000..3c8d8d5
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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;
+  fill?: string;
+  size?: number;
+  style?: React.CSSProperties;
+}
+
+export default function SettingsIcon({
+  className,
+  fill = 'currentColor',
+  size = 14,
+  style
+}: Props) {
+  return (
+    <svg
+      className={className}
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 14 14"
+      width={size}
+      height={size}
+      style={style}>
+      <g transform="matrix(0.0364583,0,0,0.0364583,0,-1.16667)">
+        <path
+          d="M256,224C256,206.333 249.75,191.25 237.25,178.75C224.75,166.25 209.667,160 192,160C174.333,160 159.25,166.25 146.75,178.75C134.25,191.25 128,206.333 128,224C128,241.667 134.25,256.75 146.75,269.25C159.25,281.75 174.333,288 192,288C209.667,288 224.75,281.75 237.25,269.25C249.75,256.75 256,241.667 256,224ZM384,196.75L384,252.25C384,254.25 383.333,256.167 382,258C380.667,259.833 379,260.917 377,261.25L330.75,268.25C327.583,277.25 324.333,284.833 321,291C326.833,299.333 335.75,310.833 347.75,325.5C349.417,327.5 350.25,329.583 350.25,331.75C350.25,333.917 349.5,335.833 348,337.5C343.5,343.667 335.25,352.667 323.25,364.5C311.25,376.333 303.417,382.25 299.75,382.25C297.75,382.25 295.583,381.5 293.25,380L258.75,353C251.417,356.833 243.833,360 236,362.5C233.333,385.167 230.917,400.667 228.75,409C227.583,413.667 224.583,416 219.75,416L164.25,416C161.917,416 159.875,415.292 158.125,413.875C156.375,412.458 155.417,410.667 155.25,408.5L148.25,362.5C140.083,359.833 132.583,356.75 125.75,353.25L90.5,380C88.833,381.5 86.75,382.25 84.25,382.25C81.917,382.25 79.833,381.333 78,379.5C57,360.5 43.25,346.5 36.75,337.5C35.583,335.833 35,333.917 35,331.75C35,329.75 35.667,327.833 37,326C39.5,322.5 43.75,316.958 49.75,309.375C55.75,301.792 60.25,295.917 63.25,291.75C58.75,283.417 55.333,275.167 53,267L7.25,260.25C5.083,259.917 3.333,258.875 2,257.125C0.667,255.375 0,253.417 0,251.25L0,195.75C0,193.75 0.667,191.833 2,190C3.333,188.167 4.917,187.083 6.75,186.75L53.25,179.75C55.583,172.083 58.833,164.417 63,156.75C56.333,147.25 47.417,135.75 36.25,122.25C34.583,120.25 33.75,118.25 33.75,116.25C33.75,114.583 34.5,112.667 36,110.5C40.333,104.5 48.542,95.542 60.625,83.625C72.708,71.708 80.583,65.75 84.25,65.75C86.417,65.75 88.583,66.583 90.75,68.25L125.25,95C132.583,91.167 140.167,88 148,85.5C150.667,62.833 153.083,47.333 155.25,39C156.417,34.333 159.417,32 164.25,32L219.75,32C222.083,32 224.125,32.708 225.875,34.125C227.625,35.542 228.583,37.333 228.75,39.5L235.75,85.5C243.917,88.167 251.417,91.25 258.25,94.75L293.75,68C295.25,66.5 297.25,65.75 299.75,65.75C301.917,65.75 304,66.583 306,68.25C327.5,88.083 341.25,102.25 347.25,110.75C348.417,112.083 349,113.917 349,116.25C349,118.25 348.333,120.167 347,122C344.5,125.5 340.25,131.042 334.25,138.625C328.25,146.208 323.75,152.083 320.75,156.25C325.083,164.583 328.5,172.75 331,180.75L376.75,187.75C378.917,188.083 380.667,189.125 382,190.875C383.333,192.625 384,194.583 384,196.75Z"
+          style={{ fill }}
+        />
+      </g>
+    </svg>
+  );
+}
index 3121933a32a8cfdf26f6e439b88858bf8bdacc71..d46555151634ac36f69e66404d2ddc393fc8435c 100644 (file)
@@ -228,11 +228,11 @@ ul.modal-head-metadata li {
 }
 
 .modal-foot {
-  text-align: right;
-  padding: 8px 10px;
+  line-height: 24px;
+  padding: 10px;
   border-top: 1px solid #ccc;
-  line-height: 30px;
   background-color: #efefef;
+  text-align: right;
 
   button,
   .button,
index 2c7b0e75f88b4c74249804c4905adc5d487fcf05..fcdfb29ff25cf2a04035cb238fea7059196f226b 100644 (file)
@@ -343,9 +343,6 @@ a:hover > .icon-radio {
   content: '\f03a';
   font-size: @iconSmallFontSize;
 }
-.icon-settings:before {
-  content: '\f015';
-}
 .icon-bulk-change:before {
   content: '\f085';
   font-size: @iconSmallFontSize;
index 661dde6c15e93f3d6e3d482b7640ffad28c88557..5f553cff29771b24c54647f21bc3796ba26f9928 100644 (file)
@@ -143,6 +143,7 @@ reload=Reload
 remove=Remove
 rename=Rename
 reset_verb=Reset
+reset_to_default=Reset To Default
 resolution=Resolution
 restart=Restart
 restore=Restore
@@ -591,6 +592,9 @@ application_deletion.page.description=Delete this application. Application proje
 provisioning.page=Provisioning
 provisioning.page.description=Use this page to initialize projects if you would like to configure them before the first analysis. Once a project is provisioned, you have access to perform all project configurations on it.
 project_branches.page=Branches
+project_branches.page.description=Use this page to manage project branches.
+project_branches.page.life_time=Short living branches are permanently deleted when there is no analysis for {days} days.
+project_branches.page.life_time.admin=Short living branches are permanently deleted when there is no analysis for {days} days. You can change this parameter in {settings}.
 
 #------------------------------------------------------------------------------
 #
@@ -1073,6 +1077,8 @@ property.error.notFloat=Not a floating point number
 property.error.notRegexp=Not a valid Java regular expression
 property.error.notInOptions=Not a valid option
 property.category.scm=SCM
+property.sonar.leak.period.description=Period used to compare measures and track new issues. Values are:<ul class='bullet'><li>Number of days before  analysis, for example 5.</li><li>A custom date. Format is yyyy-MM-dd, for example 2010-12-25</li><li>'previous_version' to compare to the previous version in the project history</li><li>A version, for example '1.2' or 'BASELINE'</li></ul><p>When specifying a number of days or a date, the snapshot selected for comparison is the first one available inside the corresponding time range. </p><p>Changing this property only takes effect after subsequent project inspections.<p/>
+property.sonar.branch.longLivedBranches.regex.description=Regular expression used to detect whether a branch is a long living branch (as opposed to short living branch), based on its name. This applies only during first analysis, the type of a branch cannot be changed later.
 
 #------------------------------------------------------------------------------
 #
@@ -3207,8 +3213,10 @@ branches.orphan_branches=Orphan Branches
 branches.orphan_branches.tooltip=When a target branch of a short-living branch was deleted, this short-living branch becomes orphan.
 branches.main_branch=Main Branch
 branches.branch_settings=Branch Settings
-branches.settings_hint=To administrate your branches, you have to go to your main branch's {link} tab.
-branches.settings_hint_tab=Administration > Branches
+branches.long_living_branches_pattern=Long living branches pattern
+branches.detection_of_long_living_branches=Detection of long living branches
+branches.detection_of_long_living_branches.description=Regular expression used to detect whether a branch is a long living branch (as opposed to short living branch), based on its name. This applies only during first analysis, the type of a branch cannot be changed later.
+branches.set_leak_period=Set leak period
 
 
 #------------------------------------------------------------------------------