]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19197 Allow issues to be filtered by code variant
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Tue, 9 May 2023 13:52:44 +0000 (15:52 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 16 May 2023 20:02:50 +0000 (20:02 +0000)
14 files changed:
server/sonar-web/src/main/js/api/issues.ts
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/apps/issues/utils.ts
server/sonar-web/src/main/js/helpers/mocks/issues.ts
server/sonar-web/src/main/js/types/issues.ts
server/sonar-web/src/main/js/types/types.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index a0675a1dc09437213f65df11e9c3ac8e3ce823a0..af5ee866a1cbd3f60764a563503f8b17fc081a1a 100644 (file)
@@ -35,6 +35,7 @@ type FacetName =
   | 'assigned_to_me'
   | 'assignees'
   | 'author'
+  | 'codeVariants'
   | 'createdAt'
   | 'cwe'
   | 'directories'
index 1a7c78136c6a2b7e516e4cbd5e8b229f122958df..d79208bedcda1e4a4a24304a200b466d8852bcb2 100644 (file)
@@ -418,6 +418,7 @@ export default class IssuesServiceMock {
           ruleStatus: 'DEPRECATED',
           quickFixAvailable: true,
           tags: ['unused'],
+          codeVariants: ['variant 1', 'variant 2'],
           project: 'org.project2',
           assignee: 'email1@sonarsource.com',
           author: 'email3@sonarsource.com',
@@ -477,7 +478,7 @@ export default class IssuesServiceMock {
 
     this.list = cloneDeep(this.defaultList);
 
-    (searchIssues as jest.Mock).mockImplementation(this.handleSearchIssues);
+    jest.mocked(searchIssues).mockImplementation(this.handleSearchIssues);
     (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
     jest.mocked(searchRules).mockImplementation(this.handleSearchRules);
     (getIssueFlowSnippets as jest.Mock).mockImplementation(this.handleGetIssueFlowSnippets);
@@ -648,6 +649,27 @@ export default class IssuesServiceMock {
           ],
         };
       }
+      if (name === 'codeVariants') {
+        return {
+          property: 'codeVariants',
+          values: this.list.reduce((acc, { issue }) => {
+            if (issue.codeVariants?.length) {
+              issue.codeVariants.forEach((codeVariant) => {
+                const item = acc.find(({ val }) => val === codeVariant);
+                if (item) {
+                  item.count++;
+                } else {
+                  acc.push({
+                    val: codeVariant,
+                    count: 1,
+                  });
+                }
+              });
+            }
+            return acc;
+          }, [] as RawFacet['values']),
+        };
+      }
       if (name === 'projects') {
         return {
           property: name,
@@ -757,7 +779,18 @@ export default class IssuesServiceMock {
       .filter(
         (item) =>
           !query.inNewCodePeriod || new Date(item.issue.creationDate) > new Date('2023-01-10')
-      );
+      )
+      .filter((item) => {
+        if (!query.codeVariants) {
+          return true;
+        }
+        if (!item.issue.codeVariants) {
+          return false;
+        }
+        return item.issue.codeVariants.some((codeVariant) =>
+          query.codeVariants?.split(',').includes(codeVariant)
+        );
+      });
 
     // Splice list items according to paging using a fixed page size
     const pageIndex = query.p || 1;
index a7c11cbc3ca81e8326514e0f07d561573616c656..c7a6a5f9c8149cd0bcd1095699dc6ec429b4c696 100644 (file)
@@ -22,7 +22,7 @@ import userEvent from '@testing-library/user-event';
 import selectEvent from 'react-select-event';
 import { TabKeys } from '../../../components/rules/RuleTabViewer';
 import { renderOwaspTop102021Category } from '../../../helpers/security-standard';
-import { mockLoggedInUser } from '../../../helpers/testMocks';
+import { mockLoggedInUser, mockRawIssue } from '../../../helpers/testMocks';
 import { ComponentQualifier } from '../../../types/component';
 import { IssueType } from '../../../types/issues';
 import {
@@ -419,6 +419,35 @@ describe('issues app', () => {
       expect(ui.issueItem7.get()).toBeInTheDocument();
     });
 
+    it('should properly filter by code variants', async () => {
+      const user = userEvent.setup();
+      renderProjectIssuesApp();
+      await waitOnDataLoaded();
+
+      await user.click(ui.codeVariantsFacet.get());
+      await user.click(screen.getByRole('checkbox', { name: /variant 1/ }));
+
+      expect(ui.issueItem1.query()).not.toBeInTheDocument();
+      expect(ui.issueItem7.get()).toBeInTheDocument();
+
+      // Clear filter
+      await user.click(ui.clearCodeVariantsFacet.get());
+      expect(ui.issueItem1.get()).toBeInTheDocument();
+    });
+
+    it('should properly hide the code variants filter if no issue has any code variants', async () => {
+      issuesHandler.setIssueList([
+        {
+          issue: mockRawIssue(),
+          snippets: {},
+        },
+      ]);
+      renderProjectIssuesApp();
+      await waitOnDataLoaded();
+
+      expect(ui.codeVariantsFacet.query()).not.toBeInTheDocument();
+    });
+
     it('should allow to set creation date', async () => {
       const user = userEvent.setup();
       const currentUser = mockLoggedInUser();
index 50d36594ac6c1a0933e3dcdad62f2301467cf04f..8668a496f6cc7ae0f5195fff7ae221faded48426 100644 (file)
@@ -36,6 +36,7 @@ describe('serialize/deserialize', () => {
         assigned: true,
         assignees: ['a', 'b'],
         author: ['a', 'b'],
+        codeVariants: ['variant1', 'variant2'],
         createdAfter: new Date(1000000),
         createdAt: 'a',
         createdBefore: new Date(1000000),
@@ -67,6 +68,7 @@ describe('serialize/deserialize', () => {
     ).toStrictEqual({
       assignees: 'a,b',
       author: ['a', 'b'],
+      codeVariants: 'variant1,variant2',
       createdAt: 'a',
       createdBefore: '1970-01-01',
       createdAfter: '1970-01-01',
index ffad87ea9bb965964eba1e27348570b90176a4bd..5626e1f10f09ce235337b0b6f07ac5c4db924e7e 100644 (file)
@@ -64,7 +64,7 @@ import {
 } from '../../../helpers/pages';
 import { serializeDate } from '../../../helpers/query';
 import { BranchLike } from '../../../types/branch-like';
-import { ComponentQualifier, isPortfolioLike } from '../../../types/component';
+import { ComponentQualifier, isPortfolioLike, isProject } from '../../../types/component';
 import {
   ASSIGNEE_ME,
   Facet,
@@ -128,6 +128,7 @@ export interface State {
   locationsNavigator: boolean;
   myIssues: boolean;
   openFacets: Dict<boolean>;
+  showVariantsFilter: boolean;
   openIssue?: Issue;
   openPopup?: { issue: string; name: string };
   openRuleDetails?: RuleDetails;
@@ -146,6 +147,7 @@ export interface State {
 const DEFAULT_QUERY = { resolved: 'false' };
 const MAX_INITAL_FETCH = 1000;
 const BRANCH_STATUS_REFRESH_INTERVAL = 1000;
+const VARIANTS_FACET = 'codeVariants';
 
 export class App extends React.PureComponent<Props, State> {
   mounted = false;
@@ -178,6 +180,7 @@ export class App extends React.PureComponent<Props, State> {
         standards: shouldOpenStandardsFacet({}, query),
         types: true,
       },
+      showVariantsFilter: false,
       query,
       referencedComponentsById: {},
       referencedComponentsByKey: {},
@@ -212,7 +215,7 @@ export class App extends React.PureComponent<Props, State> {
     addWhitePageClass();
     addSideBarClass();
     this.attachShortcuts();
-    this.fetchFirstIssues();
+    this.fetchFirstIssues(true);
   }
 
   componentDidUpdate(prevProps: Props, prevState: State) {
@@ -226,7 +229,7 @@ export class App extends React.PureComponent<Props, State> {
       !areQueriesEqual(prevQuery, query) ||
       areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query)
     ) {
-      this.fetchFirstIssues();
+      this.fetchFirstIssues(false);
       this.setState({ checkAll: false });
     } else if (openIssue && openIssue.key !== this.state.selected) {
       this.setState({
@@ -439,16 +442,24 @@ export class App extends React.PureComponent<Props, State> {
     });
   };
 
-  fetchIssues = (additional: RawQuery, requestFacets = false): Promise<FetchIssuesPromise> => {
+  fetchIssues = (
+    additional: RawQuery,
+    requestFacets = false,
+    firstRequest = false
+  ): Promise<FetchIssuesPromise> => {
     const { component } = this.props;
     const { myIssues, openFacets, query } = this.state;
 
-    const facets = requestFacets
+    let facets = requestFacets
       ? Object.keys(openFacets)
           .filter((facet) => facet !== STANDARDS && openFacets[facet])
           .join(',')
       : undefined;
 
+    if (firstRequest && isProject(component?.qualifier)) {
+      facets = facets ? `${facets},${VARIANTS_FACET}` : VARIANTS_FACET;
+    }
+
     const parameters: Dict<string | undefined> = {
       ...getBranchLikeQuery(this.props.branchLike),
       componentKeys: component && component.key,
@@ -475,7 +486,7 @@ export class App extends React.PureComponent<Props, State> {
     return this.fetchIssuesHelper(parameters);
   };
 
-  fetchFirstIssues() {
+  fetchFirstIssues(firstRequest: boolean) {
     const prevQuery = this.props.location.query;
     const openIssueKey = getOpen(this.props.location.query);
     let fetchPromise;
@@ -492,7 +503,7 @@ export class App extends React.PureComponent<Props, State> {
         return pageIssues.some((issue) => issue.key === openIssueKey);
       });
     } else {
-      fetchPromise = this.fetchIssues({}, true);
+      fetchPromise = this.fetchIssues({}, true, firstRequest);
     }
 
     return fetchPromise.then(
@@ -503,10 +514,13 @@ export class App extends React.PureComponent<Props, State> {
           if (issues.length > 0) {
             selected = openIssue ? openIssue.key : issues[0].key;
           }
-          this.setState({
+          this.setState(({ showVariantsFilter }) => ({
             cannotShowOpenIssue: Boolean(openIssueKey && !openIssue),
             effortTotal,
             facets: parseFacets(facets),
+            showVariantsFilter: firstRequest
+              ? Boolean(facets.find((f) => f.property === VARIANTS_FACET)?.values.length)
+              : showVariantsFilter,
             loading: false,
             locationsNavigator: true,
             issues,
@@ -520,7 +534,7 @@ export class App extends React.PureComponent<Props, State> {
             selected,
             selectedFlowIndex: 0,
             selectedLocationIndex: undefined,
-          });
+          }));
         }
         return issues;
       },
@@ -786,7 +800,7 @@ export class App extends React.PureComponent<Props, State> {
   handleBulkChangeDone = () => {
     this.setState({ checkAll: false });
     this.refreshBranchStatus();
-    this.fetchFirstIssues();
+    this.fetchFirstIssues(false);
     this.handleCloseBulkChange();
   };
 
@@ -891,7 +905,19 @@ export class App extends React.PureComponent<Props, State> {
 
   renderFacets() {
     const { component, currentUser, branchLike } = this.props;
-    const { query } = this.state;
+    const {
+      query,
+      facets,
+      loadingFacets,
+      myIssues,
+      openFacets,
+      showVariantsFilter,
+      referencedComponentsById,
+      referencedComponentsByKey,
+      referencedLanguages,
+      referencedRules,
+      referencedUsers,
+    } = this.state;
 
     return (
       <div className="layout-page-filters">
@@ -912,19 +938,20 @@ export class App extends React.PureComponent<Props, State> {
           branchLike={branchLike}
           component={component}
           createdAfterIncludesTime={this.createdAfterIncludesTime()}
-          facets={this.state.facets}
+          facets={facets}
           loadSearchResultCount={this.loadSearchResultCount}
-          loadingFacets={this.state.loadingFacets}
-          myIssues={this.state.myIssues}
+          loadingFacets={loadingFacets}
+          myIssues={myIssues}
           onFacetToggle={this.handleFacetToggle}
           onFilterChange={this.handleFilterChange}
-          openFacets={this.state.openFacets}
+          openFacets={openFacets}
+          showVariantsFilter={showVariantsFilter}
           query={query}
-          referencedComponentsById={this.state.referencedComponentsById}
-          referencedComponentsByKey={this.state.referencedComponentsByKey}
-          referencedLanguages={this.state.referencedLanguages}
-          referencedRules={this.state.referencedRules}
-          referencedUsers={this.state.referencedUsers}
+          referencedComponentsById={referencedComponentsById}
+          referencedComponentsByKey={referencedComponentsByKey}
+          referencedLanguages={referencedLanguages}
+          referencedRules={referencedRules}
+          referencedUsers={referencedUsers}
         />
       </div>
     );
index 90460b7b8aaf6a48c4003a11d7ee09dfdd2c285b..ea5ce2bfbed0cf407994f6c40ce8944a82466858 100644 (file)
@@ -26,6 +26,7 @@ import {
   ComponentQualifier,
   isApplication,
   isPortfolioLike,
+  isProject,
   isView,
 } from '../../../types/component';
 import {
@@ -54,6 +55,7 @@ import StandardFacet from './StandardFacet';
 import StatusFacet from './StatusFacet';
 import TagFacet from './TagFacet';
 import TypeFacet from './TypeFacet';
+import VariantFacet from './VariantFacet';
 
 export interface Props {
   appState: AppState;
@@ -67,6 +69,7 @@ export interface Props {
   onFacetToggle: (property: string) => void;
   onFilterChange: (changes: Partial<Query>) => void;
   openFacets: Dict<boolean>;
+  showVariantsFilter: boolean;
   query: Query;
   referencedComponentsById: Dict<ReferencedComponent>;
   referencedComponentsByKey: Dict<ReferencedComponent>;
@@ -77,7 +80,8 @@ export interface Props {
 
 export class Sidebar extends React.PureComponent<Props> {
   renderComponentFacets() {
-    const { component, facets, loadingFacets, openFacets, query, branchLike } = this.props;
+    const { component, facets, loadingFacets, openFacets, query, branchLike, showVariantsFilter } =
+      this.props;
     const hasFileOrDirectory =
       !isApplication(component?.qualifier) && !isPortfolioLike(component?.qualifier);
     if (!component || !hasFileOrDirectory) {
@@ -102,6 +106,15 @@ export class Sidebar extends React.PureComponent<Props> {
             {...commonProps}
           />
         )}
+        {showVariantsFilter && isProject(component?.qualifier) && (
+          <VariantFacet
+            fetching={loadingFacets.codeVariants === true}
+            open={!!openFacets.codeVariants}
+            stats={facets.codeVariants}
+            values={query.codeVariants}
+            {...commonProps}
+          />
+        )}
         <FileFacet
           branchLike={branchLike}
           fetching={loadingFacets.files === true}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx
new file mode 100644 (file)
index 0000000..954f0bb
--- /dev/null
@@ -0,0 +1,110 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { orderBy, sortBy, without } from 'lodash';
+import * as React from 'react';
+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 { translate } from '../../../helpers/l10n';
+import { Dict } from '../../../types/types';
+import { Query, formatFacetStat } from '../utils';
+
+interface VariantFacetProps {
+  fetching: boolean;
+  onChange: (changes: Partial<Query>) => void;
+  onToggle: (property: string) => void;
+  open: boolean;
+  stats?: Dict<number>;
+  values: string[];
+}
+
+const FACET_NAME = 'codeVariants';
+
+export default function VariantFacet(props: VariantFacetProps) {
+  const { open, fetching, stats = {}, values, onToggle, onChange } = props;
+
+  const handleClear = React.useCallback(() => {
+    onChange({ [FACET_NAME]: undefined });
+  }, [onChange]);
+
+  const handleHeaderClick = React.useCallback(() => {
+    onToggle(FACET_NAME);
+  }, [onToggle]);
+
+  const handleItemClick = React.useCallback(
+    (value: string, multiple: boolean) => {
+      if (value === '') {
+        onChange({ [FACET_NAME]: undefined });
+      } else if (multiple) {
+        const newValues = orderBy(
+          values.includes(value) ? without(values, value) : [...values, value]
+        );
+        onChange({ [FACET_NAME]: newValues });
+      } else {
+        onChange({
+          [FACET_NAME]: values.includes(value) && values.length === 1 ? [] : [value],
+        });
+      }
+    },
+    [values, onChange]
+  );
+
+  const id = `facet_${FACET_NAME}`;
+
+  return (
+    <FacetBox property={FACET_NAME}>
+      <FacetHeader
+        fetching={fetching}
+        name={translate('issues.facet', FACET_NAME)}
+        id={id}
+        onClear={handleClear}
+        onClick={handleHeaderClick}
+        open={open}
+        values={values}
+      />
+      {open && (
+        <>
+          <FacetItemsList labelledby={id}>
+            {Object.keys(stats).length === 0 && (
+              <div className="note spacer-bottom">{translate('no_results')}</div>
+            )}
+            {sortBy(
+              Object.keys(stats),
+              (key) => -stats[key],
+              (key) => key
+            ).map((codeVariant) => (
+              <FacetItem
+                active={values.includes(codeVariant)}
+                key={codeVariant}
+                name={codeVariant}
+                onClick={handleItemClick}
+                stat={formatFacetStat(stats[codeVariant])}
+                value={codeVariant}
+              />
+            ))}
+          </FacetItemsList>
+          <MultipleSelectionHint options={Object.keys(stats).length} values={values.length} />
+        </>
+      )}
+    </FacetBox>
+  );
+}
index 52fdec70fee967f376008f5f5697d98cee424c38..06d816f076e6dc4f937db8beb0926d0e84ad316f 100644 (file)
@@ -117,6 +117,7 @@ function renderSidebar(props: Partial<Sidebar['props']> = {}) {
       onFacetToggle={jest.fn()}
       onFilterChange={jest.fn()}
       openFacets={{}}
+      showVariantsFilter={false}
       query={mockQuery()}
       referencedComponentsById={{}}
       referencedComponentsByKey={{}}
index 7a9b2d9ff7374d6be9dfaf6bfee45ec2f12c92b8..2f0129612df3137cdaabb87c976a224de6cb0f84 100644 (file)
@@ -76,9 +76,11 @@ export const ui = {
   projectFacet: byRole('button', { name: 'issues.facet.projects' }),
   clearProjectFacet: byRole('button', { name: 'clear_x_filter.issues.facet.projects' }),
   assigneeFacet: byRole('button', { name: 'issues.facet.assignees' }),
+  codeVariantsFacet: byRole('button', { name: 'issues.facet.codeVariants' }),
   clearAssigneeFacet: byRole('button', { name: 'clear_x_filter.issues.facet.assignees' }),
   authorFacet: byRole('button', { name: 'issues.facet.authors' }),
   clearAuthorFacet: byRole('button', { name: 'clear_x_filter.issues.facet.authors' }),
+  clearCodeVariantsFacet: byRole('button', { name: 'clear_x_filter.issues.facet.codeVariants' }),
 
   dateInputMonthSelect: byRole('combobox', { name: 'Month:' }),
   dateInputYearSelect: byRole('combobox', { name: 'Year:' }),
index 400718aa38177c2819c32b8678e048fad48e99da..d2aee46d24437db925bb39568f1f42524dd46fe8 100644 (file)
@@ -34,6 +34,7 @@ import {
 import { get, save } from '../../helpers/storage';
 import { isDefined } from '../../helpers/types';
 import { Facet, RawFacet } from '../../types/issues';
+import { MetricType } from '../../types/metrics';
 import { SecurityStandard } from '../../types/security';
 import { Dict, Issue, Paging, RawQuery } from '../../types/types';
 import { UserBase } from '../../types/users';
@@ -44,6 +45,7 @@ export interface Query {
   assigned: boolean;
   assignees: string[];
   author: string[];
+  codeVariants: string[];
   createdAfter: Date | undefined;
   createdAt: string;
   createdBefore: Date | undefined;
@@ -111,6 +113,7 @@ export function parseQuery(query: RawQuery): Query {
     statuses: parseAsArray(query.statuses, parseAsString),
     tags: parseAsArray(query.tags, parseAsString),
     types: parseAsArray(query.types, parseAsString),
+    codeVariants: parseAsArray(query.codeVariants, parseAsString),
   };
 }
 
@@ -157,6 +160,7 @@ export function serializeQuery(query: Query): RawQuery {
     statuses: serializeStringArray(query.statuses),
     tags: serializeStringArray(query.tags),
     types: serializeStringArray(query.types),
+    codeVariants: serializeStringArray(query.codeVariants),
   };
 
   return cleanQuery(filter);
@@ -182,7 +186,7 @@ export function parseFacets(facets: RawFacet[]): Dict<Facet> {
 }
 
 export function formatFacetStat(stat: number | undefined) {
-  return stat && formatMeasure(stat, 'SHORT_INT');
+  return stat && formatMeasure(stat, MetricType.ShortInteger);
 }
 
 export const searchAssignees = (
index 7eb14844271181ad7e161ddae8ee4f3112b726e9..fe626de3c3a4fc8647c1e1418ddcbe1df4794e4e 100644 (file)
@@ -71,6 +71,7 @@ export function mockQuery(overrides: Partial<Query> = {}): Query {
     assigned: false,
     assignees: [],
     author: [],
+    codeVariants: [],
     createdAfter: undefined,
     createdAt: '',
     createdBefore: undefined,
index 3e96f1cf3c75e8cf8cbd1b2fc72b890c68fe331a..7b55991936b7ee379852aedd8d0099b7498387d3 100644 (file)
@@ -109,6 +109,7 @@ export interface RawIssue {
   tags?: string[];
   assignee?: string;
   author?: string;
+  codeVariants?: string[];
   comments?: Comment[];
   creationDate: string;
   component: string;
index 29415171dfdfc6978d21af50146de9dffc499c74..94901ddc87ab48c96184e3c4d63529fc62a0a0a8 100644 (file)
@@ -246,6 +246,7 @@ export interface Issue {
   assigneeName?: string;
   author?: string;
   branch?: string;
+  codeVariants?: string[];
   comments?: IssueComment[];
   component: string;
   componentEnabled?: boolean;
index 9daa1ddc8ffa2028b3d132db784da24734269f42..ac422445838b2eda45338b8b8231ff4013c4cf13 100644 (file)
@@ -1019,6 +1019,7 @@ issues.facet.tags=Tag
 issues.facet.rules=Rule
 issues.facet.resolutions=Resolution
 issues.facet.languages=Language
+issues.facet.codeVariants=Code Variant
 issues.facet.createdAt=Creation Date
 issues.facet.createdAt.all=All
 issues.facet.createdAt.last_week=Last week