]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-13597 Add scope distribution to issues page
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Fri, 10 Jul 2020 15:04:41 +0000 (17:04 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 3 Sep 2020 20:07:20 +0000 (20:07 +0000)
14 files changed:
.gitignore
server/sonar-web/src/main/js/apps/issues/components/ComponentBreadcrumbs.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/ComponentBreadcrumbs-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/ComponentBreadcrumbs-test.tsx.snap
server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ScopeFacet-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ScopeFacet-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/apps/issues/utils.ts
server/sonar-web/src/main/js/helpers/constants.ts
server/sonar-web/src/main/js/types/issues.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index efb7abc3de6c8298f1b303baaa47d81fad730efb..37062d90dc9da7768eda2cb69de37c57ae8d6dfa 100644 (file)
@@ -68,3 +68,4 @@ scripts/patches/*license*.txt
 !scripts/patches/debug_ce.sh
 !scripts/patches/debug_web.sh
 !scripts/patches/postgres.sh
+gherkin-features/
index b0e134740ce2bfec303da7f352d50bb077272c21..0b0cfdc2cd06ee7010c684fc0de5e8510314f921 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon';
 import { collapsePath, limitComponentName } from 'sonar-ui-common/helpers/path';
 import Organization from '../../../components/shared/Organization';
 import { getSelectedLocation } from '../utils';
@@ -28,6 +29,7 @@ interface Props {
     T.Issue,
     | 'component'
     | 'componentLongName'
+    | 'componentQualifier'
     | 'flows'
     | 'organization'
     | 'project'
@@ -59,6 +61,8 @@ export default function ComponentBreadcrumbs({
 
   return (
     <div className="component-name text-ellipsis">
+      <QualifierIcon className="spacer-right" qualifier={issue.componentQualifier} />
+
       {displayOrganization && <Organization link={false} organizationKey={issue.organization} />}
 
       {displayProject && (
index d2fb5d9295f5aee302a833c96ce6b124651e5d0c..0b2dd2ca53f5fca404d1c93a5a6b06d4edb4e43f 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { ComponentQualifier } from '../../../../types/component';
 import ComponentBreadcrumbs from '../ComponentBreadcrumbs';
 
 const baseIssue = {
   component: 'comp',
   componentLongName: 'comp-name',
+  componentQualifier: ComponentQualifier.File,
   flows: [],
   organization: 'org',
   project: 'proj',
index 82ae1824db0053fa11ccb5641b1ce92e517a5584..33e198ab34ef7a7569d39e8ff08b9b3f6075c3bd 100644 (file)
@@ -4,6 +4,10 @@ exports[`renders 1`] = `
 <div
   className="component-name text-ellipsis"
 >
+  <QualifierIcon
+    className="spacer-right"
+    qualifier="FIL"
+  />
   <Connect(Organization)
     link={false}
     organizationKey="org"
@@ -28,6 +32,10 @@ exports[`renders with sub-project 1`] = `
 <div
   className="component-name text-ellipsis"
 >
+  <QualifierIcon
+    className="spacer-right"
+    qualifier="FIL"
+  />
   <Connect(Organization)
     link={false}
     organizationKey="org"
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx
new file mode 100644 (file)
index 0000000..301a3c2
--- /dev/null
@@ -0,0 +1,96 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { without } from 'lodash';
+import * as React from 'react';
+import QualifierIcon from 'sonar-ui-common/components/icons/QualifierIcon';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import FacetBox from '../../../components/facet/FacetBox';
+import FacetHeader from '../../../components/facet/FacetHeader';
+import FacetItem from '../../../components/facet/FacetItem';
+import FacetItemsList from '../../../components/facet/FacetItemsList';
+import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
+import { SOURCE_SCOPES } from '../../../helpers/constants';
+import { formatFacetStat, Query } from '../utils';
+
+export interface ScopeFacetProps {
+  fetching: boolean;
+  onChange: (changes: Partial<Query>) => void;
+  onToggle: (property: string) => void;
+  open: boolean;
+  scopes: string[];
+  stats: T.Dict<number> | undefined;
+}
+
+export default function ScopeFacet(props: ScopeFacetProps) {
+  const { fetching, open, scopes = [], stats = {} } = props;
+  const values = scopes.map(scope => translate('issue.scope', scope));
+
+  return (
+    <FacetBox property="scopes">
+      <FacetHeader
+        fetching={fetching}
+        name={translate('issues.facet.scopes')}
+        onClear={() => props.onChange({ scopes: [] })}
+        onClick={() => props.onToggle('scopes')}
+        open={open}
+        values={values}
+      />
+
+      {open && (
+        <>
+          <FacetItemsList>
+            {SOURCE_SCOPES.map(({ scope, qualifier }) => {
+              const active = scopes.includes(scope);
+              const stat = stats[scope];
+
+              return (
+                <FacetItem
+                  active={active}
+                  disabled={stat === 0 && !active}
+                  key={scope}
+                  name={
+                    <span className="display-flex-center">
+                      <QualifierIcon className="little-spacer-right" qualifier={qualifier} />{' '}
+                      {translate('issue.scope', scope)}
+                    </span>
+                  }
+                  onClick={(itemValue: string, multiple: boolean) => {
+                    if (multiple) {
+                      props.onChange({
+                        scopes: active ? without(scopes, itemValue) : [...scopes, itemValue]
+                      });
+                    } else {
+                      props.onChange({
+                        scopes: active && scopes.length === 1 ? [] : [itemValue]
+                      });
+                    }
+                  }}
+                  stat={formatFacetStat(stat)}
+                  value={scope}
+                />
+              );
+            })}
+          </FacetItemsList>
+          <MultipleSelectionHint options={Object.keys(stats).length} values={scopes.length} />
+        </>
+      )}
+    </FacetBox>
+  );
+}
index 8890583195b6fbec2e92df92ddc914d6bfae7b11..878cac3cae837b018486f0766cfede6e39a3e165 100644 (file)
@@ -32,6 +32,7 @@ import LanguageFacet from './LanguageFacet';
 import ProjectFacet from './ProjectFacet';
 import ResolutionFacet from './ResolutionFacet';
 import RuleFacet from './RuleFacet';
+import ScopeFacet from './ScopeFacet';
 import SeverityFacet from './SeverityFacet';
 import StandardFacet from './StandardFacet';
 import StatusFacet from './StatusFacet';
@@ -126,6 +127,14 @@ export class Sidebar extends React.PureComponent<Props> {
           severities={query.severities}
           stats={facets.severities}
         />
+        <ScopeFacet
+          fetching={this.props.loadingFacets.scopes === true}
+          onChange={this.props.onFilterChange}
+          onToggle={this.props.onFacetToggle}
+          open={!!openFacets.scopes}
+          stats={facets.scopes}
+          scopes={query.scopes}
+        />
         <ResolutionFacet
           fetching={this.props.loadingFacets.resolutions === true}
           onChange={this.props.onFilterChange}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ScopeFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ScopeFacet-test.tsx
new file mode 100644 (file)
index 0000000..8922677
--- /dev/null
@@ -0,0 +1,94 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import FacetHeader from '../../../../components/facet/FacetHeader';
+import FacetItem from '../../../../components/facet/FacetItem';
+import { IssueScope } from '../../../../types/issues';
+import ScopeFacet, { ScopeFacetProps } from '../ScopeFacet';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ open: true })).toMatchSnapshot('open');
+  expect(shallowRender({ open: true, scopes: [IssueScope.Main] })).toMatchSnapshot('active facet');
+  expect(shallowRender({ open: true, stats: { [IssueScope.Main]: 0 } })).toMatchSnapshot(
+    'disabled facet'
+  );
+});
+
+it('should correctly handle facet header clicks', () => {
+  const onChange = jest.fn();
+  const onToggle = jest.fn();
+  const wrapper = shallowRender({ onChange, onToggle });
+
+  wrapper.find(FacetHeader).props().onClear!();
+  expect(onChange).toBeCalledWith({ scopes: [] });
+
+  wrapper.find(FacetHeader).props().onClick!();
+  expect(onToggle).toBeCalledWith('scopes');
+});
+
+it('should correctly handle facet item clicks', () => {
+  const wrapper = shallowRender({ open: true, scopes: [IssueScope.Main] });
+  const onChange = jest.fn(({ scopes }) => wrapper.setProps({ scopes }));
+  wrapper.setProps({ onChange });
+
+  clickFacetItem(wrapper, IssueScope.Test);
+  expect(onChange).toHaveBeenLastCalledWith({ scopes: [IssueScope.Test] });
+
+  clickFacetItem(wrapper, IssueScope.Test);
+  expect(onChange).toHaveBeenLastCalledWith({ scopes: [] });
+
+  clickFacetItem(wrapper, IssueScope.Test, true);
+  clickFacetItem(wrapper, IssueScope.Main, true);
+  expect(onChange).toHaveBeenLastCalledWith({
+    scopes: expect.arrayContaining([IssueScope.Main, IssueScope.Test])
+  });
+
+  clickFacetItem(wrapper, IssueScope.Test, true);
+  expect(onChange).toHaveBeenLastCalledWith({ scopes: [IssueScope.Main] });
+});
+
+function clickFacetItem(
+  wrapper: ShallowWrapper<ScopeFacetProps>,
+  scope: IssueScope,
+  multiple = false
+) {
+  return wrapper
+    .find(FacetItem)
+    .filterWhere(f => f.key() === scope)
+    .props()
+    .onClick(scope, multiple);
+}
+
+function shallowRender(props: Partial<ScopeFacetProps> = {}) {
+  return shallow<ScopeFacetProps>(
+    <ScopeFacet
+      fetching={true}
+      onChange={jest.fn()}
+      onToggle={jest.fn()}
+      open={false}
+      scopes={[]}
+      stats={{}}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ScopeFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ScopeFacet-test.tsx.snap
new file mode 100644 (file)
index 0000000..ca871a4
--- /dev/null
@@ -0,0 +1,210 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: active facet 1`] = `
+<FacetBox
+  property="scopes"
+>
+  <FacetHeader
+    fetching={true}
+    name="issues.facet.scopes"
+    onClear={[Function]}
+    onClick={[Function]}
+    open={true}
+    values={
+      Array [
+        "issue.scope.MAIN",
+      ]
+    }
+  />
+  <FacetItemsList>
+    <FacetItem
+      active={true}
+      disabled={false}
+      halfWidth={false}
+      key="MAIN"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="FIL"
+          />
+           
+          issue.scope.MAIN
+        </span>
+      }
+      onClick={[Function]}
+      value="MAIN"
+    />
+    <FacetItem
+      active={false}
+      disabled={false}
+      halfWidth={false}
+      key="TEST"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="UTS"
+          />
+           
+          issue.scope.TEST
+        </span>
+      }
+      onClick={[Function]}
+      value="TEST"
+    />
+  </FacetItemsList>
+  <MultipleSelectionHint
+    options={0}
+    values={1}
+  />
+</FacetBox>
+`;
+
+exports[`should render correctly: default 1`] = `
+<FacetBox
+  property="scopes"
+>
+  <FacetHeader
+    fetching={true}
+    name="issues.facet.scopes"
+    onClear={[Function]}
+    onClick={[Function]}
+    open={false}
+    values={Array []}
+  />
+</FacetBox>
+`;
+
+exports[`should render correctly: disabled facet 1`] = `
+<FacetBox
+  property="scopes"
+>
+  <FacetHeader
+    fetching={true}
+    name="issues.facet.scopes"
+    onClear={[Function]}
+    onClick={[Function]}
+    open={true}
+    values={Array []}
+  />
+  <FacetItemsList>
+    <FacetItem
+      active={false}
+      disabled={true}
+      halfWidth={false}
+      key="MAIN"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="FIL"
+          />
+           
+          issue.scope.MAIN
+        </span>
+      }
+      onClick={[Function]}
+      stat={0}
+      value="MAIN"
+    />
+    <FacetItem
+      active={false}
+      disabled={false}
+      halfWidth={false}
+      key="TEST"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="UTS"
+          />
+           
+          issue.scope.TEST
+        </span>
+      }
+      onClick={[Function]}
+      value="TEST"
+    />
+  </FacetItemsList>
+  <MultipleSelectionHint
+    options={1}
+    values={0}
+  />
+</FacetBox>
+`;
+
+exports[`should render correctly: open 1`] = `
+<FacetBox
+  property="scopes"
+>
+  <FacetHeader
+    fetching={true}
+    name="issues.facet.scopes"
+    onClear={[Function]}
+    onClick={[Function]}
+    open={true}
+    values={Array []}
+  />
+  <FacetItemsList>
+    <FacetItem
+      active={false}
+      disabled={false}
+      halfWidth={false}
+      key="MAIN"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="FIL"
+          />
+           
+          issue.scope.MAIN
+        </span>
+      }
+      onClick={[Function]}
+      value="MAIN"
+    />
+    <FacetItem
+      active={false}
+      disabled={false}
+      halfWidth={false}
+      key="TEST"
+      loading={false}
+      name={
+        <span
+          className="display-flex-center"
+        >
+          <QualifierIcon
+            className="little-spacer-right"
+            qualifier="UTS"
+          />
+           
+          issue.scope.TEST
+        </span>
+      }
+      onClick={[Function]}
+      value="TEST"
+    />
+  </FacetItemsList>
+  <MultipleSelectionHint
+    options={0}
+    values={0}
+  />
+</FacetBox>
+`;
index 09df17ecf9bf344e5841f0d9c5807f3040195742..5168fd8c58efbe9887bbd4615f8857c6e9afd67b 100644 (file)
@@ -4,6 +4,7 @@ exports[`should not render developer nominative facets when asked not to 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -19,6 +20,7 @@ exports[`should render facets for developer 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -37,6 +39,7 @@ exports[`should render facets for directory 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -54,6 +57,7 @@ exports[`should render facets for global page 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -71,6 +75,7 @@ exports[`should render facets for module 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -89,6 +94,7 @@ exports[`should render facets for project 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
@@ -107,6 +113,7 @@ exports[`should render facets when my issues are selected 1`] = `
 Array [
   "TypeFacet",
   "SeverityFacet",
+  "ScopeFacet",
   "ResolutionFacet",
   "StatusFacet",
   "StandardFacet",
index 1c166a3a06518f2dd1131432d10a4c0c9a0d0058..69417bf91e422edc6eb7cbe88843649a9c4926bd 100644 (file)
 }
 
 .issues-workspace-list-component {
-  padding: 10px 10px 6px;
+  padding: 10px 0 6px;
 }
 
 .issues-workspace-list-item + .issues-workspace-list-item {
index f486bf64fb5d07135e2a9863761caf271571faaa..5d25e6d22a011abb62232a63962316b734b91520 100644 (file)
@@ -54,6 +54,7 @@ export interface Query {
   resolved: boolean;
   rules: string[];
   sansTop25: string[];
+  scopes: string[];
   severities: string[];
   sinceLeakPeriod: boolean;
   sonarsourceSecurity: string[];
@@ -96,6 +97,7 @@ export function parseQuery(query: T.RawQuery): Query {
     resolved: parseAsBoolean(query.resolved),
     rules: parseAsArray(query.rules, parseAsString),
     sansTop25: parseAsArray(query.sansTop25, parseAsString),
+    scopes: parseAsArray(query.scopes, parseAsString),
     severities: parseAsArray(query.severities, parseAsString),
     sinceLeakPeriod: parseAsBoolean(query.sinceLeakPeriod, false),
     sonarsourceSecurity: parseAsArray(query.sonarsourceSecurity, parseAsString),
@@ -134,6 +136,7 @@ export function serializeQuery(query: Query): T.RawQuery {
     rules: serializeStringArray(query.rules),
     s: serializeString(query.sort),
     sansTop25: serializeStringArray(query.sansTop25),
+    scopes: serializeStringArray(query.scopes),
     severities: serializeStringArray(query.severities),
     sinceLeakPeriod: query.sinceLeakPeriod ? 'true' : undefined,
     sonarsourceSecurity: serializeStringArray(query.sonarsourceSecurity),
index 2e101a490d0f6d56982d0c56d7bf2feec46539f7..5bf81e56973c435f022a1251a1ef8104a6f4476b 100644 (file)
@@ -18,7 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { colors } from '../app/theme';
-import { IssueType } from '../types/issues';
+import { ComponentQualifier } from '../types/component';
+import { IssueScope, IssueType } from '../types/issues';
 
 export const SEVERITIES = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'];
 export const STATUSES = ['OPEN', 'REOPENED', 'CONFIRMED', 'RESOLVED', 'CLOSED'];
@@ -28,6 +29,10 @@ export const ISSUE_TYPES: T.IssueType[] = [
   IssueType.CodeSmell,
   IssueType.SecurityHotspot
 ];
+export const SOURCE_SCOPES = [
+  { scope: IssueScope.Main, qualifier: ComponentQualifier.File },
+  { scope: IssueScope.Test, qualifier: ComponentQualifier.TestFile }
+];
 export const RULE_TYPES: T.RuleType[] = ['BUG', 'VULNERABILITY', 'CODE_SMELL', 'SECURITY_HOTSPOT'];
 export const RULE_STATUSES = ['READY', 'BETA', 'DEPRECATED'];
 
index a9d2ae5cb8b5fb8cafec883327aa1608745d4864..ee0613d6a574eb5ddc3b0c9ac4b6ba898bae1740 100644 (file)
@@ -24,3 +24,8 @@ export enum IssueType {
   Bug = 'BUG',
   SecurityHotspot = 'SECURITY_HOTSPOT'
 }
+
+export enum IssueScope {
+  Main = 'MAIN',
+  Test = 'TEST'
+}
index 71c985dbd21d6563986dd98cf1a19729cb73ff5b..219683c2bc40853914f5b5e3f4d4732bc7c57bac 100644 (file)
@@ -785,6 +785,9 @@ issue.status.TO_REVIEW=To Review
 issue.status.IN_REVIEW=In Review
 issue.status.REVIEWED=Reviewed
 
+issue.scope.MAIN=Main code
+issue.scope.TEST=Test code
+
 issue.resolution.FALSE-POSITIVE=False Positive
 issue.resolution.FALSE-POSITIVE.description=Issues that manual review determined were False Positives. Effort from these issues is ignored.
 issue.resolution.FIXED=Fixed
@@ -849,6 +852,7 @@ issue.changelog.field.file=File
 #------------------------------------------------------------------------------
 issues.facet.types=Type
 issues.facet.severities=Severity
+issues.facet.scopes=Scope
 issues.facet.projects=Project
 issues.facet.statuses=Status
 issues.facet.hotspotStatuses=Hotspot Status