]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11409 Enable rule activation from the Compare page
authorStas Vilchik <stas.vilchik@sonarsource.com>
Wed, 19 Dec 2018 16:32:39 +0000 (17:32 +0100)
committerSonarTech <sonartech@sonarsource.com>
Fri, 21 Dec 2018 19:21:01 +0000 (20:21 +0100)
server/sonar-web/src/main/js/api/quality-profiles.ts
server/sonar-web/src/main/js/apps/coding-rules/components/ActivationButton.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/ActivationFormModal.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonContainer.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonForm.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultActivation.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResults.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/ComparisonResultActivation-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/ComparisonResults-test.tsx
server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/__snapshots__/ComparisonResultActivation-test.tsx.snap [new file with mode: 0644]

index cbdabad47ddeb7f9e3ec98367c9f4b07f44c81fd..b236bfa4910df8a099a088eacd0fc787bb76c4ad 100644 (file)
@@ -161,7 +161,20 @@ export function getProfileChangelog(data: RequestData): Promise<any> {
   return getJSON('/api/qualityprofiles/changelog', data);
 }
 
-export function compareProfiles(leftKey: string, rightKey: string): Promise<any> {
+export interface CompareResponse {
+  left: { name: string };
+  right: { name: string };
+  inLeft: Array<{ key: string; name: string; severity: string }>;
+  inRight: Array<{ key: string; name: string; severity: string }>;
+  modified: Array<{
+    key: string;
+    name: string;
+    left: { params: { [p: string]: string }; severity: string };
+    right: { params: { [p: string]: string }; severity: string };
+  }>;
+}
+
+export function compareProfiles(leftKey: string, rightKey: string): Promise<CompareResponse> {
   return getJSON('/api/qualityprofiles/compare', { leftKey, rightKey });
 }
 
index be5294254e746b418bf34dfb8f9e9c4087b65331..65daf3aff73b759e3218729c4b544596835754d5 100644 (file)
@@ -31,7 +31,6 @@ interface Props {
   organization: string | undefined;
   profiles: BaseProfile[];
   rule: T.Rule | T.RuleDetails;
-  updateMode?: boolean;
 }
 
 interface State {
@@ -39,17 +38,8 @@ interface State {
 }
 
 export default class ActivationButton extends React.PureComponent<Props, State> {
-  mounted = false;
   state: State = { modal: false };
 
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
   handleButtonClick = () => {
     this.setState({ modal: true });
   };
@@ -77,7 +67,6 @@ export default class ActivationButton extends React.PureComponent<Props, State>
             organization={this.props.organization}
             profiles={this.props.profiles}
             rule={this.props.rule}
-            updateMode={this.props.updateMode}
           />
         )}
       </>
index b60724a2a60adac3188f34813e23acc65f4818f8..8da4d9176ad5846d990c9fed3d57584d31e70d99 100644 (file)
@@ -36,7 +36,6 @@ interface Props {
   organization: string | undefined;
   profiles: BaseProfile[];
   rule: T.Rule | T.RuleDetails;
-  updateMode?: boolean;
 }
 
 interface State {
index 9e28e8002000d13f7edbeb8d8f66f002d162c90d..9c7171535d4cb7afa8dc6bc75e2026b493147816 100644 (file)
@@ -21,31 +21,18 @@ import * as React from 'react';
 import { withRouter, WithRouterProps } from 'react-router';
 import ComparisonForm from './ComparisonForm';
 import ComparisonResults from './ComparisonResults';
-import { compareProfiles } from '../../../api/quality-profiles';
+import { compareProfiles, CompareResponse } from '../../../api/quality-profiles';
 import { getProfileComparePath } from '../utils';
 import { Profile } from '../types';
 
 interface Props extends WithRouterProps {
-  organization: string | null;
+  organization?: string;
   profile: Profile;
   profiles: Profile[];
 }
 
-type Params = { [p: string]: string };
-
-interface State {
-  loading: boolean;
-  left?: { name: string };
-  right?: { name: string };
-  inLeft?: Array<{ key: string; name: string; severity: string }>;
-  inRight?: Array<{ key: string; name: string; severity: string }>;
-  modified?: Array<{
-    key: string;
-    name: string;
-    left: { params: Params; severity: string };
-    right: { params: Params; severity: string };
-  }>;
-}
+type State = { loading: boolean } & Partial<CompareResponse>;
+type StateWithResults = { loading: boolean } & CompareResponse;
 
 class ComparisonContainer extends React.PureComponent<Props, State> {
   mounted = false;
@@ -66,27 +53,27 @@ class ComparisonContainer extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  loadResults() {
+  loadResults = () => {
     const { withKey } = this.props.location.query;
     if (!withKey) {
       this.setState({ left: undefined, loading: false });
-      return;
+      return Promise.resolve();
     }
 
     this.setState({ loading: true });
-    compareProfiles(this.props.profile.key, withKey).then((r: any) => {
-      if (this.mounted) {
-        this.setState({
-          left: r.left,
-          right: r.right,
-          inLeft: r.inLeft,
-          inRight: r.inRight,
-          modified: r.modified,
-          loading: false
-        });
+    return compareProfiles(this.props.profile.key, withKey).then(
+      ({ left, right, inLeft, inRight, modified }) => {
+        if (this.mounted) {
+          this.setState({ left, right, inLeft, inRight, modified, loading: false });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
       }
-    });
-  }
+    );
+  };
 
   handleCompare = (withKey: string) => {
     const path = getProfileComparePath(
@@ -98,10 +85,13 @@ class ComparisonContainer extends React.PureComponent<Props, State> {
     this.props.router.push(path);
   };
 
+  hasResults(state: State): state is StateWithResults {
+    return state.left !== undefined;
+  }
+
   render() {
     const { profile, profiles, location } = this.props;
     const { withKey } = location.query;
-    const { left, right, inLeft, inRight, modified } = this.state;
 
     return (
       <div className="boxed-group boxed-group-inner js-profile-comparison">
@@ -116,22 +106,21 @@ class ComparisonContainer extends React.PureComponent<Props, State> {
           {this.state.loading && <i className="spinner spacer-left" />}
         </header>
 
-        {left != null &&
-          inLeft != null &&
-          right != null &&
-          inRight != null &&
-          modified != null && (
-            <div className="spacer-top">
-              <ComparisonResults
-                inLeft={inLeft}
-                inRight={inRight}
-                left={left}
-                modified={modified}
-                organization={this.props.organization}
-                right={right}
-              />
-            </div>
-          )}
+        {this.hasResults(this.state) && (
+          <div className="spacer-top">
+            <ComparisonResults
+              inLeft={this.state.inLeft}
+              inRight={this.state.inRight}
+              left={this.state.left}
+              leftProfile={profile}
+              modified={this.state.modified}
+              organization={this.props.organization}
+              refresh={this.loadResults}
+              right={this.state.right}
+              rightProfile={profiles.find(p => p.key === withKey)}
+            />
+          </div>
+        )}
       </div>
     );
   }
index 5cbba2b10cca8ea509cc79c704d8cab21e8c79f9..0681f741ffab88ba2f37d5ec247279c4189ca6cb 100644 (file)
@@ -30,9 +30,9 @@ interface Props {
 }
 
 export default class ComparisonForm extends React.PureComponent<Props> {
-  handleChange(option: { value: string }) {
+  handleChange = (option: { value: string }) => {
     this.props.onCompare(option.value);
-  }
+  };
 
   render() {
     const { profile, profiles, withKey } = this.props;
@@ -46,7 +46,7 @@ export default class ComparisonForm extends React.PureComponent<Props> {
         <Select
           className="input-large"
           clearable={false}
-          onChange={this.handleChange.bind(this)}
+          onChange={this.handleChange}
           options={options}
           placeholder={translate('select_verb')}
           value={withKey}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultActivation.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonResultActivation.tsx
new file mode 100644 (file)
index 0000000..06a1767
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 { Profile } from '../../../api/quality-profiles';
+import { lazyLoad } from '../../../components/lazyLoad';
+import { Button } from '../../../components/ui/buttons';
+import { translate } from '../../../helpers/l10n';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { getRuleDetails } from '../../../api/rules';
+
+const ActivationFormModal = lazyLoad(
+  () => import('../../coding-rules/components/ActivationFormModal'),
+  'ActivationFormModal'
+);
+
+interface Props {
+  onDone: () => Promise<void>;
+  organization?: string;
+  profile: Profile;
+  ruleKey: string;
+}
+
+interface State {
+  rule?: T.RuleDetails;
+  state: 'closed' | 'opening' | 'open';
+}
+
+export default class ComparisonResultActivation extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = { state: 'closed' };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleButtonClick = () => {
+    this.setState({ state: 'opening' });
+    getRuleDetails({ key: this.props.ruleKey, organization: this.props.organization }).then(
+      ({ rule }) => {
+        if (this.mounted) {
+          this.setState({ rule, state: 'open' });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ state: 'closed' });
+        }
+      }
+    );
+  };
+
+  handleCloseModal = () => {
+    this.setState({ state: 'closed' });
+  };
+
+  isOpen(state: State): state is { state: 'open'; rule: T.RuleDetails } {
+    return state.state === 'open';
+  }
+
+  render() {
+    const { profile } = this.props;
+
+    const canActivate = !profile.isBuiltIn && profile.actions && profile.actions.edit;
+    if (!canActivate) {
+      return null;
+    }
+
+    return (
+      <DeferredSpinner loading={this.state.state === 'opening'}>
+        <Button disabled={this.state.state !== 'closed'} onClick={this.handleButtonClick}>
+          {this.props.children}
+        </Button>
+
+        {this.isOpen(this.state) && (
+          <ActivationFormModal
+            modalHeader={translate('coding_rules.activate_in_quality_profile')}
+            onClose={this.handleCloseModal}
+            onDone={this.props.onDone}
+            organization={this.props.organization}
+            profiles={[profile]}
+            rule={this.state.rule}
+          />
+        )}
+      </DeferredSpinner>
+    );
+  }
+}
index b4938f4cf1b69cdbdb170f09623816fe6c504ff1..45272343cead3c53173c9a8c381a4ab3b676c7ba 100644 (file)
 import * as React from 'react';
 import { Link } from 'react-router';
 import ComparisonEmpty from './ComparisonEmpty';
+import ComparisonResultActivation from './ComparisonResultActivation';
 import SeverityIcon from '../../../components/icons-components/SeverityIcon';
 import { translateWithParameters } from '../../../helpers/l10n';
 import { getRulesUrl } from '../../../helpers/urls';
+import { CompareResponse, Profile } from '../../../api/quality-profiles';
+import ChevronRightIcon from '../../../components/icons-components/ChevronRightcon';
+import ChevronLeftIcon from '../../../components/icons-components/ChevronLeftIcon';
 
 type Params = { [p: string]: string };
 
-interface Props {
-  left: { name: string };
-  right: { name: string };
-  inLeft: Array<{ key: string; name: string; severity: string }>;
-  inRight: Array<{ key: string; name: string; severity: string }>;
-  modified: Array<{
-    key: string;
-    name: string;
-    left: { params: Params; severity: string };
-    right: { params: Params; severity: string };
-  }>;
-  organization: string | null;
+interface Props extends CompareResponse {
+  organization?: string;
+  leftProfile: Profile;
+  refresh: () => Promise<void>;
+  rightProfile?: Profile;
 }
 
 export default class ComparisonResults extends React.PureComponent<Props> {
@@ -45,7 +42,9 @@ export default class ComparisonResults extends React.PureComponent<Props> {
     return (
       <div>
         <SeverityIcon severity={severity} />{' '}
-        <Link to={getRulesUrl({ rule_key: rule.key }, this.props.organization)}>{rule.name}</Link>
+        <Link to={getRulesUrl({ rule_key: rule.key, open: rule.key }, this.props.organization)}>
+          {rule.name}
+        </Link>
       </div>
     );
   }
@@ -90,7 +89,18 @@ export default class ComparisonResults extends React.PureComponent<Props> {
         {this.props.inLeft.map(rule => (
           <tr className="js-comparison-in-left" key={`left-${rule.key}`}>
             <td>{this.renderRule(rule, rule.severity)}</td>
-            <td>&nbsp;</td>
+            <td>
+              {this.props.rightProfile && (
+                <ComparisonResultActivation
+                  key={rule.key}
+                  onDone={this.props.refresh}
+                  organization={this.props.organization || undefined}
+                  profile={this.props.rightProfile}
+                  ruleKey={rule.key}>
+                  <ChevronRightIcon />
+                </ComparisonResultActivation>
+              )}
+            </td>
           </tr>
         ))}
       </>
@@ -117,7 +127,16 @@ export default class ComparisonResults extends React.PureComponent<Props> {
         </tr>
         {this.props.inRight.map(rule => (
           <tr className="js-comparison-in-right" key={`right-${rule.key}`}>
-            <td>&nbsp;</td>
+            <td className="text-right">
+              <ComparisonResultActivation
+                key={rule.key}
+                onDone={this.props.refresh}
+                organization={this.props.organization || undefined}
+                profile={this.props.leftProfile}
+                ruleKey={rule.key}>
+                <ChevronLeftIcon />
+              </ComparisonResultActivation>
+            </td>
             <td>{this.renderRule(rule, rule.severity)}</td>
           </tr>
         ))}
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/ComparisonResultActivation-test.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/ComparisonResultActivation-test.tsx
new file mode 100644 (file)
index 0000000..dd50416
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 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 ComparisonResultActivation from '../ComparisonResultActivation';
+import { Profile } from '../../../../api/quality-profiles';
+import { click, waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../api/rules', () => ({
+  getRuleDetails: jest.fn().mockResolvedValue({ key: 'foo' })
+}));
+
+it('should activate', async () => {
+  const profile = { actions: { edit: true }, key: 'profile-key' } as Profile;
+  const wrapper = shallow(
+    <ComparisonResultActivation onDone={jest.fn()} profile={profile} ruleKey="foo" />
+  );
+  expect(wrapper).toMatchSnapshot();
+
+  click(wrapper.find('Button'));
+  expect(wrapper).toMatchSnapshot();
+
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.find('ActivationFormModal').prop<Function>('onClose')();
+  expect(wrapper).toMatchSnapshot();
+});
index 6ae04a58e3730f29990198022f7a8b8031c27fce..88c64004d31d805f747365d98ec15f3f78741ae5 100644 (file)
@@ -22,6 +22,7 @@ import * as React from 'react';
 import { Link } from 'react-router';
 import ComparisonResults from '../ComparisonResults';
 import ComparisonEmpty from '../ComparisonEmpty';
+import { Profile } from '../../../../api/quality-profiles';
 
 it('should render ComparisonEmpty', () => {
   const output = shallow(
@@ -29,8 +30,9 @@ it('should render ComparisonEmpty', () => {
       inLeft={[]}
       inRight={[]}
       left={{ name: 'left' }}
+      leftProfile={{} as Profile}
       modified={[]}
-      organization={null}
+      refresh={jest.fn()}
       right={{ name: 'right' }}
     />
   );
@@ -63,8 +65,9 @@ it('should compare', () => {
       inLeft={inLeft}
       inRight={inRight}
       left={{ name: 'left' }}
+      leftProfile={{} as Profile}
       modified={modified}
-      organization={null}
+      refresh={jest.fn()}
       right={{ name: 'right' }}
     />
   );
@@ -72,7 +75,10 @@ it('should compare', () => {
   const leftDiffs = output.find('.js-comparison-in-left');
   expect(leftDiffs.length).toBe(1);
   expect(leftDiffs.find(Link).length).toBe(1);
-  expect(leftDiffs.find(Link).prop('to')).toHaveProperty('query', { rule_key: 'rule1' });
+  expect(leftDiffs.find(Link).prop('to')).toHaveProperty('query', {
+    rule_key: 'rule1',
+    open: 'rule1'
+  });
   expect(leftDiffs.find(Link).prop('children')).toContain('rule1');
   expect(leftDiffs.find('SeverityIcon').length).toBe(1);
   expect(leftDiffs.find('SeverityIcon').prop('severity')).toBe('BLOCKER');
@@ -85,7 +91,7 @@ it('should compare', () => {
       .at(0)
       .find(Link)
       .prop('to')
-  ).toHaveProperty('query', { rule_key: 'rule2' });
+  ).toHaveProperty('query', { rule_key: 'rule2', open: 'rule2' });
   expect(
     rightDiffs
       .at(0)
@@ -107,7 +113,7 @@ it('should compare', () => {
       .find(Link)
       .at(0)
       .prop('to')
-  ).toHaveProperty('query', { rule_key: 'rule4' });
+  ).toHaveProperty('query', { rule_key: 'rule4', open: 'rule4' });
   expect(
     modifiedDiffs
       .find(Link)
diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/__snapshots__/ComparisonResultActivation-test.tsx.snap b/server/sonar-web/src/main/js/apps/quality-profiles/compare/__tests__/__snapshots__/ComparisonResultActivation-test.tsx.snap
new file mode 100644 (file)
index 0000000..fe54900
--- /dev/null
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should activate 1`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <Button
+    disabled={false}
+    onClick={[Function]}
+  />
+</DeferredSpinner>
+`;
+
+exports[`should activate 2`] = `
+<DeferredSpinner
+  loading={true}
+  timeout={100}
+>
+  <Button
+    disabled={true}
+    onClick={[Function]}
+  />
+</DeferredSpinner>
+`;
+
+exports[`should activate 3`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <Button
+    disabled={true}
+    onClick={[Function]}
+  />
+  <ActivationFormModal
+    modalHeader="coding_rules.activate_in_quality_profile"
+    onClose={[Function]}
+    onDone={[MockFunction]}
+    profiles={
+      Array [
+        Object {
+          "actions": Object {
+            "edit": true,
+          },
+          "key": "profile-key",
+        },
+      ]
+    }
+  />
+</DeferredSpinner>
+`;
+
+exports[`should activate 4`] = `
+<DeferredSpinner
+  loading={false}
+  timeout={100}
+>
+  <Button
+    disabled={false}
+    onClick={[Function]}
+  />
+</DeferredSpinner>
+`;