]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19750 Unlock project hotspots page
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Tue, 18 Jul 2023 14:49:39 +0000 (16:49 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 19 Jul 2023 20:03:05 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/security-hotspots.ts
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 4369330003a23364b167543bd9883678d53709bd..3b3cd10bab1449684b185f16c98224e86da44670 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { throwGlobalError } from '../helpers/error';
 import { getJSON, post } from '../helpers/request';
 import { BranchParameters } from '../types/branch-like';
@@ -31,6 +32,7 @@ import {
 } from '../types/security-hotspots';
 import { UserBase } from '../types/users';
 
+const HOTSPOTS_LIST_URL = '/api/hotspots/list';
 const HOTSPOTS_SEARCH_URL = '/api/hotspots/search';
 
 export function assignSecurityHotspot(
@@ -70,27 +72,34 @@ export function editSecurityHotspotComment(
 
 export function getSecurityHotspots(
   data: {
-    projectKey: string;
+    inNewCodePeriod?: boolean;
+    onlyMine?: boolean;
     p: number;
+    projectKey: string;
     ps: number;
-    status?: HotspotStatus;
     resolution?: HotspotResolution;
-    onlyMine?: boolean;
-    inNewCodePeriod?: boolean;
-  } & BranchParameters
+    status?: HotspotStatus;
+  } & BranchParameters,
+  projectIsIndexing = false
 ): Promise<HotspotSearchResponse> {
-  return getJSON(HOTSPOTS_SEARCH_URL, data).catch(throwGlobalError);
+  return getJSON(
+    projectIsIndexing ? HOTSPOTS_LIST_URL : HOTSPOTS_SEARCH_URL,
+    projectIsIndexing ? { ...data, project: data.projectKey } : data
+  ).catch(throwGlobalError);
 }
 
 export function getSecurityHotspotList(
   hotspotKeys: string[],
   data: {
     projectKey: string;
-  } & BranchParameters
+  } & BranchParameters,
+  projectIsIndexing = false
 ): Promise<HotspotSearchResponse> {
-  return getJSON(HOTSPOTS_SEARCH_URL, { ...data, hotspots: hotspotKeys.join() }).catch(
-    throwGlobalError
-  );
+  return getJSON(projectIsIndexing ? HOTSPOTS_LIST_URL : HOTSPOTS_SEARCH_URL, {
+    ...data,
+    hotspots: hotspotKeys.join(),
+    ...(projectIsIndexing ? { project: data.projectKey } : {}),
+  }).catch(throwGlobalError);
 }
 
 export function getSecurityHotspotDetails(securityHotspotKey: string): Promise<Hotspot> {
@@ -105,10 +114,12 @@ export function getSecurityHotspotDetails(securityHotspotKey: string): Promise<H
             login: hotspot.assignee,
           };
         }
+
         hotspot.authorUser = users.find((u) => u.login === hotspot.author) || {
           active: true,
           login: hotspot.author,
         };
+
         hotspot.comment.forEach((c) => {
           c.user = users.find((u) => u.login === c.login) || { active: true, login: c.login };
         });
index 3d503e44137908a6ceaf178d24ef4b221e776946..795d7fc2ed757fcb61a9266e4bd968955c0ec5fa 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { flatMap, range } from 'lodash';
 import * as React from 'react';
 import { getMeasures } from '../../api/measures';
@@ -31,6 +32,7 @@ import { KeyboardKeys } from '../../helpers/keycodes';
 import { getStandards } from '../../helpers/security-standard';
 import { withBranchLikes } from '../../queries/branch';
 import { BranchLike } from '../../types/branch-like';
+import { MetricKey } from '../../types/metrics';
 import { SecurityStandard, Standards } from '../../types/security';
 import {
   HotspotFilters,
@@ -81,25 +83,25 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     super(props);
 
     this.state = {
+      filters: {
+        ...this.constructFiltersFromProps(props),
+        status: HotspotStatusFilter.TO_REVIEW,
+      },
+      hotspots: [],
+      hotspotsPageIndex: 1,
+      hotspotsTotal: 0,
       loading: true,
       loadingMeasure: false,
       loadingMore: false,
-      hotspots: [],
-      hotspotsTotal: 0,
-      hotspotsPageIndex: 1,
       selectedHotspot: undefined,
       standards: {
-        [SecurityStandard.OWASP_TOP10]: {},
-        [SecurityStandard.OWASP_TOP10_2021]: {},
-        [SecurityStandard.SONARSOURCE]: {},
         [SecurityStandard.CWE]: {},
+        [SecurityStandard.OWASP_ASVS_4_0]: {},
+        [SecurityStandard.OWASP_TOP10_2021]: {},
+        [SecurityStandard.OWASP_TOP10]: {},
         [SecurityStandard.PCI_DSS_3_2]: {},
         [SecurityStandard.PCI_DSS_4_0]: {},
-        [SecurityStandard.OWASP_ASVS_4_0]: {},
-      },
-      filters: {
-        ...this.constructFiltersFromProps(props),
-        status: HotspotStatusFilter.TO_REVIEW,
+        [SecurityStandard.SONARSOURCE]: {},
       },
     };
   }
@@ -146,6 +148,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     if (isInput(event)) {
       return;
     }
+
     if (event.key === KeyboardKeys.Alt) {
       event.preventDefault();
       return;
@@ -154,20 +157,24 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     switch (event.key) {
       case KeyboardKeys.DownArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectNextLocation();
         } else {
           this.selectNeighboringHotspot(+1);
         }
+
         break;
       }
       case KeyboardKeys.UpArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectPreviousLocation();
         } else {
           this.selectNeighboringHotspot(-1);
         }
+
         break;
       }
     }
@@ -175,16 +182,21 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
 
   selectNextLocation = () => {
     const { selectedHotspotLocationIndex, selectedHotspot } = this.state;
+
     if (selectedHotspot === undefined) {
       return;
     }
+
     const locations = getLocations(selectedHotspot.flows, undefined);
+
     if (locations.length === 0) {
       return;
     }
+
     const lastIndex = locations.length - 1;
 
     let newIndex;
+
     if (selectedHotspotLocationIndex === undefined) {
       newIndex = 0;
     } else if (selectedHotspotLocationIndex === lastIndex) {
@@ -192,6 +204,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     } else {
       newIndex = selectedHotspotLocationIndex + 1;
     }
+
     this.setState({ selectedHotspotLocationIndex: newIndex });
   };
 
@@ -199,21 +212,25 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     const { selectedHotspotLocationIndex } = this.state;
 
     let newIndex;
+
     if (selectedHotspotLocationIndex === 0) {
       newIndex = undefined;
     } else if (selectedHotspotLocationIndex !== undefined) {
       newIndex = selectedHotspotLocationIndex - 1;
     }
+
     this.setState({ selectedHotspotLocationIndex: newIndex });
   };
 
   selectNeighboringHotspot = (shift: number) => {
     this.setState({ selectedHotspotLocationIndex: undefined });
+
     this.setState(({ hotspots, selectedHotspot }) => {
       const index = selectedHotspot && hotspots.findIndex((h) => h.key === selectedHotspot.key);
 
       if (index !== undefined && index > -1) {
         const newIndex = Math.max(0, Math.min(hotspots.length - 1, index + shift));
+
         return {
           selectedHotspot: hotspots[newIndex],
         };
@@ -272,10 +289,11 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     const { filters } = this.state;
 
     const reviewedHotspotsMetricKey = filters.inNewCodePeriod
-      ? 'new_security_hotspots_reviewed'
-      : 'security_hotspots_reviewed';
+      ? MetricKey.new_security_hotspots_reviewed
+      : MetricKey.security_hotspots_reviewed;
 
     this.setState({ loadingMeasure: true });
+
     return getMeasures({
       component: component.key,
       metricKeys: reviewedHotspotsMetricKey,
@@ -285,7 +303,9 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
         if (!this.mounted) {
           return;
         }
+
         const measure = measures && measures.length > 0 ? measures[0] : undefined;
+
         const hotspotsReviewedMeasure = filters.inNewCodePeriod
           ? getLeakValue(measure)
           : measure?.value;
@@ -299,6 +319,55 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
       });
   };
 
+  fetchFilteredSecurityHotspots({
+    filterByCategory,
+    filterByCWE,
+    filterByFile,
+    page,
+  }: {
+    filterByCategory:
+      | {
+          standard: SecurityStandard;
+          category: string;
+        }
+      | undefined;
+    filterByCWE: string | undefined;
+    filterByFile: string | undefined;
+    page: number;
+  }) {
+    const { branchLike, component, location } = this.props;
+    const { filters } = this.state;
+
+    const hotspotFilters: Dict<string> = {};
+
+    if (filterByCategory) {
+      hotspotFilters[filterByCategory.standard] = filterByCategory.category;
+    }
+
+    if (filterByCWE) {
+      hotspotFilters[SecurityStandard.CWE] = filterByCWE;
+    }
+
+    if (filterByFile) {
+      hotspotFilters.files = filterByFile;
+    }
+
+    hotspotFilters['owaspAsvsLevel'] = location.query['owaspAsvsLevel'];
+
+    return getSecurityHotspots(
+      {
+        ...hotspotFilters,
+        inNewCodePeriod: filters.inNewCodePeriod && Boolean(filterByFile), // only add new code period when filtering by file
+        p: page,
+        projectKey: component.key,
+        ps: PAGE_SIZE,
+        status: HotspotStatus.TO_REVIEW, // we're only interested in unresolved hotspots
+        ...getBranchLikeQuery(branchLike),
+      },
+      component.needIssueSync
+    );
+  }
+
   fetchSecurityHotspots(page = 1) {
     const { branchLike, component, location } = this.props;
     const { filters } = this.state;
@@ -310,6 +379,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     const standard = SECURITY_STANDARDS.find(
       (stnd) => stnd !== SecurityStandard.CWE && location.query[stnd] !== undefined
     );
+
     const filterByCategory = standard
       ? { standard, category: location.query[standard] }
       : undefined;
@@ -321,35 +391,22 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
     this.setState({ filterByCategory, filterByCWE, filterByFile, hotspotKeys });
 
     if (hotspotKeys && hotspotKeys.length > 0) {
-      return getSecurityHotspotList(hotspotKeys, {
-        projectKey: component.key,
-        ...getBranchLikeQuery(branchLike),
-      });
+      return getSecurityHotspotList(
+        hotspotKeys,
+        {
+          projectKey: component.key,
+          ...getBranchLikeQuery(branchLike),
+        },
+        component.needIssueSync
+      );
     }
 
     if (filterByCategory || filterByCWE || filterByFile) {
-      const hotspotFilters: Dict<string> = {};
-
-      if (filterByCategory) {
-        hotspotFilters[filterByCategory.standard] = filterByCategory.category;
-      }
-      if (filterByCWE) {
-        hotspotFilters[SecurityStandard.CWE] = filterByCWE;
-      }
-      if (filterByFile) {
-        hotspotFilters.files = filterByFile;
-      }
-
-      hotspotFilters['owaspAsvsLevel'] = location.query['owaspAsvsLevel'];
-
-      return getSecurityHotspots({
-        ...hotspotFilters,
-        projectKey: component.key,
-        p: page,
-        ps: PAGE_SIZE,
-        status: HotspotStatus.TO_REVIEW, // we're only interested in unresolved hotspots
-        inNewCodePeriod: filters.inNewCodePeriod && Boolean(filterByFile), // only add leak period when filtering by file
-        ...getBranchLikeQuery(branchLike),
+      return this.fetchFilteredSecurityHotspots({
+        filterByCategory,
+        filterByCWE,
+        filterByFile,
+        page,
       });
     }
 
@@ -363,16 +420,19 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
         ? undefined
         : HotspotResolution[filters.status];
 
-    return getSecurityHotspots({
-      projectKey: component.key,
-      p: page,
-      ps: PAGE_SIZE,
-      status,
-      resolution,
-      onlyMine: filters.assignedToMe,
-      inNewCodePeriod: filters.inNewCodePeriod,
-      ...getBranchLikeQuery(branchLike),
-    });
+    return getSecurityHotspots(
+      {
+        inNewCodePeriod: filters.inNewCodePeriod,
+        ...(component.needIssueSync ? {} : { onlyMine: filters.assignedToMe }),
+        p: page,
+        projectKey: component.key,
+        ps: PAGE_SIZE,
+        resolution,
+        status,
+        ...getBranchLikeQuery(branchLike),
+      },
+      component.needIssueSync
+    );
   }
 
   reloadSecurityHotspotList = () => {
@@ -400,6 +460,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> {
       ({ filters }) => ({ filters: { ...filters, ...changes } }),
       () => {
         this.reloadSecurityHotspotList();
+
         if (changes.inNewCodePeriod !== undefined) {
           this.fetchSecurityHotspotsReviewed();
         }
index 2950a28090e6f55e0fc1d6793371264798f323af..8253691a9fb0a3b960911b80233fea17f6928a4c 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { withTheme } from '@emotion/react';
 import styled from '@emotion/styled';
 import {
@@ -68,15 +69,15 @@ export interface SecurityHotspotsAppRendererProps {
   loadingMeasure: boolean;
   loadingMore: boolean;
   onChangeFilters: (filters: Partial<HotspotFilters>) => void;
-  onShowAllHotspots: VoidFunction;
   onHotspotClick: (hotspot: RawHotspot) => void;
-  onLocationClick: (index?: number) => void;
   onLoadMore: () => void;
+  onLocationClick: (index?: number) => void;
+  onShowAllHotspots: VoidFunction;
   onSwitchStatusFilter: (option: HotspotStatusFilter) => void;
   onUpdateHotspot: (hotspotKey: string) => Promise<void>;
+  securityCategories: StandardSecurityCategories;
   selectedHotspot?: RawHotspot;
   selectedHotspotLocation?: number;
-  securityCategories: StandardSecurityCategories;
   standards: Standards;
 }
 
@@ -97,25 +98,29 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
     loading,
     loadingMeasure,
     loadingMore,
+    onChangeFilters,
+    onShowAllHotspots,
     securityCategories,
     selectedHotspot,
     selectedHotspotLocation,
     standards,
-    onChangeFilters,
-    onShowAllHotspots,
   } = props;
 
   const isProject = component.qualifier === ComponentQualifier.Project;
 
   const { top: topScroll } = useFollowScroll();
+
   const distanceFromBottom = topScroll + window.innerHeight - document.body.clientHeight;
+
   const footerVisibleHeight =
     distanceFromBottom > -LAYOUT_FOOTER_HEIGHT ? LAYOUT_FOOTER_HEIGHT + distanceFromBottom : 0;
 
   return (
     <>
-      <Suggestions suggestions="security_hotspots" />
+      <Suggestions suggestions={MetricKey.security_hotspots} />
+
       <Helmet title={translate('hotspots.page')} />
+
       <A11ySkipTarget anchor="security_hotspots_main" />
 
       <LargeCenteredLayout id={MetricKey.security_hotspots}>
@@ -130,27 +135,28 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
                   <HotspotSidebarHeader
                     branchLike={branchLike}
                     filters={filters}
-                    isStaticListOfHotspots={isStaticListOfHotspots}
                     hotspotsReviewedMeasure={hotspotsReviewedMeasure}
+                    isStaticListOfHotspots={isStaticListOfHotspots}
                     loadingMeasure={loadingMeasure}
                     onChangeFilters={onChangeFilters}
                   />
                 </StyledSidebarHeader>
               )}
+
               <StyledSidebarContent
                 className="sw-p-4 it__hotspot-list"
                 style={{
+                  height: `calc(
+                    100vh - ${
+                      LAYOUT_GLOBAL_NAV_HEIGHT +
+                      LAYOUT_PROJECT_NAV_HEIGHT +
+                      STICKY_HEADER_HEIGHT -
+                      footerVisibleHeight
+                    }px
+                  )`,
                   top: `${
                     LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT + STICKY_HEADER_HEIGHT
                   }px`,
-                  height: `calc(
-                  100vh - ${
-                    LAYOUT_GLOBAL_NAV_HEIGHT +
-                    LAYOUT_PROJECT_NAV_HEIGHT +
-                    STICKY_HEADER_HEIGHT -
-                    footerVisibleHeight
-                  }px
-                )`,
                 }}
               >
                 <DeferredSpinner className="sw-mt-3" loading={loading}>
@@ -173,8 +179,8 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
                           onHotspotClick={props.onHotspotClick}
                           onLoadMore={props.onLoadMore}
                           onLocationClick={props.onLocationClick}
-                          selectedHotspotLocation={selectedHotspotLocation}
                           selectedHotspot={selectedHotspot}
+                          selectedHotspotLocation={selectedHotspotLocation}
                           standards={standards}
                         />
                       ) : (
@@ -197,25 +203,26 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
                 </DeferredSpinner>
               </StyledSidebarContent>
             </StyledSidebar>
+
             <StyledMain className="sw-col-span-8 sw-relative sw-ml-12">
               {hotspots.length === 0 || !selectedHotspot ? (
                 <EmptyHotspotsPage
+                  filterByFile={Boolean(filterByFile)}
                   filtered={
                     filters.assignedToMe ||
                     (isBranch(branchLike) && filters.inNewCodePeriod) ||
                     filters.status !== HotspotStatusFilter.TO_REVIEW
                   }
-                  filterByFile={Boolean(filterByFile)}
                   isStaticListOfHotspots={isStaticListOfHotspots}
                 />
               ) : (
                 <HotspotViewer
-                  hotspotsReviewedMeasure={hotspotsReviewedMeasure}
                   component={component}
                   hotspotKey={selectedHotspot.key}
+                  hotspotsReviewedMeasure={hotspotsReviewedMeasure}
+                  onLocationClick={props.onLocationClick}
                   onSwitchStatusFilter={props.onSwitchStatusFilter}
                   onUpdateHotspot={props.onUpdateHotspot}
-                  onLocationClick={props.onLocationClick}
                   selectedHotspotLocation={selectedHotspotLocation}
                   standards={standards}
                 />
index 3806173420e8b048bdd07e56ee7c125540edb96a..931c291c9e7fcb1dde0a968b81b3342559fe721c 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
@@ -33,6 +34,7 @@ import { mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils';
 import { byDisplayValue, byRole, byTestId, byText } from '../../../helpers/testSelector';
 import { ComponentContextShape } from '../../../types/component';
+import { MetricKey } from '../../../types/metrics';
 import SecurityHotspotsApp from '../SecurityHotspotsApp';
 import useScrollDownCompress from '../hooks/useScrollDownCompress';
 
@@ -50,64 +52,70 @@ jest.mock('../../../helpers/sonarlint', () => ({
   openHotspot: jest.fn().mockResolvedValue(null),
   probeSonarLintServers: jest.fn().mockResolvedValue([
     {
-      port: 1234,
-      ideName: 'VIM',
       description: 'I use VIM',
+      ideName: 'VIM',
+      port: 1234,
     },
   ]),
 }));
 jest.mock('.../../../helpers/storage');
 
 const ui = {
-  inputAssignee: byRole('combobox', { name: 'search.search_for_users' }),
+  activeAssignee: byRole('combobox', { name: 'hotspots.assignee.change_user' }),
+  activityTab: byRole('tab', { name: /hotspots.tabs.activity/ }),
+  addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }),
+  changeStatus: byRole('button', { name: 'hotspots.status.change_status' }),
+  clearFilters: byRole('menuitem', { name: 'hotspot.filters.clear' }),
+  codeContent: byRole('table'),
+  codeTab: byRole('tab', { name: /hotspots.tabs.code/ }),
+  commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }),
+  commentEditButton: byRole('button', { name: 'issue.comment.edit' }),
+  commentSubmitButton: byRole('button', { name: 'hotspots.comment.submit' }),
+  continueReviewingButton: byRole('button', { name: 'hotspots.continue_to_next_hotspot' }),
+  currentUserSelectionItem: byText('foo'),
+  dontShowSuccessDialogCheckbox: byRole('checkbox', {
+    name: 'hotspots.success_dialog.do_not_show',
+  }),
   filterAssigneeToMe: byRole('checkbox', {
     name: 'hotspot.filters.assignee.assigned_to_me',
   }),
-  clearFilters: byRole('menuitem', { name: 'hotspot.filters.clear' }),
-  filterDropdown: byRole('button', { name: 'hotspot.filters.title' }),
-  filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }),
-  filterByStatus: byRole('combobox', { name: 'hotspot.filters.status' }),
   filterByPeriod: byRole('combobox', { name: 'hotspot.filters.period' }),
+  filterByStatus: byRole('combobox', { name: 'hotspot.filters.status' }),
+  filterDropdown: byRole('button', { name: 'hotspot.filters.title' }),
   filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }),
-  noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'),
-  reviewButton: byRole('button', { name: 'hotspots.status.review' }),
-  toReviewStatus: byText('hotspots.status_option.TO_REVIEW'),
-  changeStatus: byRole('button', { name: 'hotspots.status.change_status' }),
-  hotspotTitle: (name: string | RegExp) => byRole('heading', { name }),
-  hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }),
+  filterToReview: byRole('radio', { name: 'hotspot.filters.status.to_review' }),
+  fixContent: byText('This is how to fix'),
+  fixTab: byRole('tab', { name: /hotspots.tabs.fix_recommendations/ }),
   hotpostListTitle: byText('hotspots.list_title'),
   hotspotCommentBox: byRole('textbox', { name: 'hotspots.comment.field' }),
-  commentSubmitButton: byRole('button', { name: 'hotspots.comment.submit' }),
-  commentEditButton: byRole('button', { name: 'issue.comment.edit' }),
-  commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }),
-  textboxWithText: (value: string) => byDisplayValue(value),
-  activeAssignee: byRole('combobox', { name: 'hotspots.assignee.change_user' }),
-  successGlobalMessage: byTestId('global-message__SUCCESS'),
-  currentUserSelectionItem: byText('foo'),
+  hotspotStatus: byRole('heading', { name: 'status: hotspots.status_option.FIXED' }),
+  hotspotTitle: (name: string | RegExp) => byRole('heading', { name }),
+  inputAssignee: byRole('combobox', { name: 'search.search_for_users' }),
+  noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'),
+  openInIDEButton: byRole('button', { name: 'hotspots.open_in_ide.open' }),
   panel: byTestId('security-hotspot-test'),
-  codeTab: byRole('tab', { name: /hotspots.tabs.code/ }),
-  codeContent: byRole('table'),
-  riskTab: byRole('tab', { name: /hotspots.tabs.risk_description/ }),
+  reviewButton: byRole('button', { name: 'hotspots.status.review' }),
   riskContent: byText('Root cause'),
-  vulnerabilityTab: byRole('tab', { name: /hotspots.tabs.vulnerability_description/ }),
-  vulnerabilityContent: byText('Assess'),
-  fixTab: byRole('tab', { name: /hotspots.tabs.fix_recommendations/ }),
-  fixContent: byText('This is how to fix'),
-  showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }),
-  activityTab: byRole('tab', { name: /hotspots.tabs.activity/ }),
-  addCommentButton: byRole('button', { name: 'hotspots.status.add_comment' }),
-  openInIDEButton: byRole('button', { name: 'hotspots.open_in_ide.open' }),
-  continueReviewingButton: byRole('button', { name: 'hotspots.continue_to_next_hotspot' }),
+  riskTab: byRole('tab', { name: /hotspots.tabs.risk_description/ }),
   seeStatusHotspots: byRole('button', { name: /hotspots.see_x_hotspots/ }),
-  dontShowSuccessDialogCheckbox: byRole('checkbox', {
-    name: 'hotspots.success_dialog.do_not_show',
-  }),
+  showAllHotspotLink: byRole('link', { name: 'hotspot.filters.show_all' }),
+  successGlobalMessage: byTestId('global-message__SUCCESS'),
+  textboxWithText: (value: string) => byDisplayValue(value),
+  toReviewStatus: byText('hotspots.status_option.TO_REVIEW'),
+  vulnerabilityContent: byText('Assess'),
+  vulnerabilityTab: byRole('tab', { name: /hotspots.tabs.vulnerability_description/ }),
 };
 
 const originalScrollTo = window.scrollTo;
 const hotspotsHandler = new SecurityHotspotServiceMock();
 const rulesHandles = new CodingRulesServiceMock();
 const branchHandler = new BranchesServiceMock();
+
+const mockComponentInstance = mockComponent({
+  key: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+  name: 'benflix',
+});
+
 let showDialog = 'true';
 
 jest.mocked(save).mockImplementation((_key: string, value?: string) => {
@@ -115,6 +123,7 @@ jest.mocked(save).mockImplementation((_key: string, value?: string) => {
     showDialog = value;
   }
 });
+
 jest.mocked(get).mockImplementation(() => showDialog);
 
 beforeAll(() => {
@@ -152,13 +161,15 @@ describe('rendering', () => {
     renderSecurityHotspotsApp(
       'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-2'
     );
+
     expect(await screen.findAllByText('variant 1, variant 2')).toHaveLength(2);
   });
 
   it('should render the simple list when a file is selected', async () => {
     const user = userEvent.setup();
+
     renderSecurityHotspotsApp(
-      `security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&files=src%2Findex.js`
+      `security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&files=src%2Findex.js&cwe=foo&inNewCodePeriod=true`
     );
 
     expect(ui.filterDropdown.query()).not.toBeInTheDocument();
@@ -177,6 +188,7 @@ describe('rendering', () => {
       isCompressed: true,
       resetScrollDownCompress: jest.fn(),
     }));
+
     renderSecurityHotspotsApp();
 
     expect(await ui.reviewButton.find()).toBeInTheDocument();
@@ -256,6 +268,7 @@ describe('CRUD', () => {
       saveButton: byRole('button', { name: 'hotspots.comment.submit' }),
       deleteButton: byRole('button', { name: 'delete' }),
     };
+
     const user = userEvent.setup();
     const comment = 'This is a comment from john doe';
     renderSecurityHotspotsApp();
@@ -358,6 +371,7 @@ describe('navigation', () => {
 
   it('should allow to open a hotspot in an IDE', async () => {
     const user = userEvent.setup();
+
     renderSecurityHotspotsApp();
 
     await user.click(await ui.openInIDEButton.find());
@@ -377,6 +391,7 @@ describe('navigation', () => {
         description: 'I use MS Paint cuz Ima boss',
       },
     ]);
+
     const user = userEvent.setup();
     renderSecurityHotspotsApp();
 
@@ -392,6 +407,7 @@ it('after status change, should be able to disable success dialog show', async (
   renderSecurityHotspotsApp();
   await user.click(await ui.reviewButton.find());
   await user.click(ui.toReviewStatus.get());
+
   await act(async () => {
     await user.click(ui.changeStatus.get());
   });
@@ -403,9 +419,11 @@ it('after status change, should be able to disable success dialog show', async (
   // Repeat status change and verify that dialog is not shown
   await user.click(await ui.reviewButton.find());
   await user.click(ui.toReviewStatus.get());
+
   await act(async () => {
     await user.click(ui.changeStatus.get());
   });
+
   expect(ui.continueReviewingButton.query()).not.toBeInTheDocument();
 });
 
@@ -416,33 +434,43 @@ it('should be able to filter the hotspot list', async () => {
   expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
 
   await user.click(ui.filterDropdown.get());
+
+  expect(ui.filterAssigneeToMe.get()).toBeEnabled();
+
   await user.click(ui.filterAssigneeToMe.get());
+
   expect(await ui.noHotspotForFilter.find()).toBeInTheDocument();
 
   await user.click(ui.filterToReview.get());
 
-  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
-    inNewCodePeriod: false,
-    onlyMine: true,
-    p: 1,
-    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
-    ps: 500,
-    resolution: undefined,
-    status: 'TO_REVIEW',
-  });
+  expect(getSecurityHotspots).toHaveBeenLastCalledWith(
+    {
+      inNewCodePeriod: false,
+      onlyMine: true,
+      p: 1,
+      projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+      ps: 500,
+      resolution: undefined,
+      status: 'TO_REVIEW',
+    },
+    undefined
+  );
 
   await user.click(ui.filterDropdown.get());
   await user.click(await ui.filterNewCode.find());
 
-  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
-    inNewCodePeriod: true,
-    onlyMine: true,
-    p: 1,
-    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
-    ps: 500,
-    resolution: undefined,
-    status: 'TO_REVIEW',
-  });
+  expect(getSecurityHotspots).toHaveBeenLastCalledWith(
+    {
+      inNewCodePeriod: true,
+      onlyMine: true,
+      p: 1,
+      projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+      ps: 500,
+      resolution: undefined,
+      status: 'TO_REVIEW',
+    },
+    undefined
+  );
 
   await user.click(ui.filterDropdown.get());
   await user.click(ui.clearFilters.get());
@@ -450,28 +478,38 @@ it('should be able to filter the hotspot list', async () => {
   expect(ui.hotpostListTitle.get()).toBeInTheDocument();
 });
 
+it('should disable the "assigned to me" filter if the project is indexing', async () => {
+  const user = userEvent.setup();
+
+  renderSecurityHotspotsApp(
+    'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+    { component: { ...mockComponentInstance, needIssueSync: true } }
+  );
+
+  await user.click(ui.filterDropdown.get());
+
+  expect(ui.filterAssigneeToMe.get()).toHaveAttribute('disabled');
+});
+
 function renderSecurityHotspotsApp(
   navigateTo?: string,
   component?: Partial<ComponentContextShape>
 ) {
   return renderAppWithComponentContext(
-    'security_hotspots',
-    () => <Route path="security_hotspots" element={<SecurityHotspotsApp />} />,
+    MetricKey.security_hotspots,
+    () => <Route path={MetricKey.security_hotspots} element={<SecurityHotspotsApp />} />,
     {
-      navigateTo:
-        navigateTo ??
-        'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
       currentUser: mockLoggedInUser({
         login: 'foo',
         name: 'foo',
       }),
+      navigateTo:
+        navigateTo ??
+        'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
     },
     {
       onComponentChange: jest.fn(),
-      component: mockComponent({
-        key: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
-        name: 'benflix',
-      }),
+      component: mockComponentInstance,
       ...component,
     }
   );
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotDisabledFilterTooltip.tsx
new file mode 100644 (file)
index 0000000..70e7021
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * 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 * as React from 'react';
+import DocLink from '../../../components/common/DocLink';
+import { translate } from '../../../helpers/l10n';
+
+export function HotspotDisabledFilterTooltip() {
+  return (
+    <div className="sw-body-sm sw-w-[190px]">
+      <p>
+        {translate('indexation.page_unavailable.description')}{' '}
+        {translate('indexation.filter_unavailable.description')}
+      </p>
+      <hr className="sw-mx-0 sw-my-3 sw-p-0 sw-w-full" />
+      <span className="sw-body-sm-highlight">{translate('indexation.learn_more')}</span>
+      <DocLink
+        className="sw-ml-1"
+        onMouseDown={(e) => {
+          // This tooltip content is rendered in the context of a <Dropdown>, and <DropdownToggler>
+          // captures the "focus out" event and closes the dropdown, preventing us from clicking
+          // this link. We preventDefault() to avoid this behavior.
+          e.preventDefault();
+        }}
+        to="/instance-administration/reindexing/"
+      >
+        {translate('indexation.reindexing')}
+      </DocLink>
+    </div>
+  );
+}
index ddbbdbb44c18d0d67bee4e09f6af761f95e8572a..56d26d44309440e36c45559b47e1351dd99d7416 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import {
   CoverageIndicator,
   DiscreetInteractiveIcon,
@@ -29,51 +30,59 @@ import {
   ItemHeader,
 } from 'design-system';
 import * as React from 'react';
+import withComponentContext from '../../../app/components/componentContext/withComponentContext';
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import HelpTooltip from '../../../components/controls/HelpTooltip';
+import Tooltip from '../../../components/controls/Tooltip';
 import Measure from '../../../components/measure/Measure';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { PopupPlacement } from '../../../components/ui/popups';
 import { isBranch } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { BranchLike } from '../../../types/branch-like';
+import { ComponentContextShape } from '../../../types/component';
 import { MetricKey, MetricType } from '../../../types/metrics';
 import { HotspotFilters } from '../../../types/security-hotspots';
 import { CurrentUser, isLoggedIn } from '../../../types/users';
+import { HotspotDisabledFilterTooltip } from './HotspotDisabledFilterTooltip';
 
-export interface SecurityHotspotsAppRendererProps {
+export interface SecurityHotspotsAppRendererProps extends ComponentContextShape {
   branchLike?: BranchLike;
+  currentUser: CurrentUser;
   filters: HotspotFilters;
   hotspotsReviewedMeasure?: string;
+  isStaticListOfHotspots: boolean;
   loadingMeasure: boolean;
   onChangeFilters: (filters: Partial<HotspotFilters>) => void;
-  currentUser: CurrentUser;
-  isStaticListOfHotspots: boolean;
 }
 
 function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
   const {
     branchLike,
+    component,
+    currentUser,
     filters,
     hotspotsReviewedMeasure,
-    loadingMeasure,
-    currentUser,
     isStaticListOfHotspots,
+    loadingMeasure,
   } = props;
 
   const userLoggedIn = isLoggedIn(currentUser);
+
   const filtersCount =
     Number(filters.assignedToMe) + Number(isBranch(branchLike) && filters.inNewCodePeriod);
+
   const isFiltered = Boolean(filtersCount);
 
   return (
-    <div className="sw-flex sw-py-4 sw-items-center sw-h-6 sw-px-4">
+    <div className="sw-flex sw-h-6 sw-items-center sw-px-4 sw-py-4">
       <DeferredSpinner loading={loadingMeasure}>
         {hotspotsReviewedMeasure !== undefined && (
           <CoverageIndicator value={hotspotsReviewedMeasure} />
         )}
+
         <Measure
-          className="sw-ml-2 sw-body-sm-highlight it__hs-review-percentage"
+          className="it__hs-review-percentage sw-body-sm-highlight sw-ml-2"
           metricKey={
             isBranch(branchLike) && !filters.inNewCodePeriod
               ? MetricKey.security_hotspots_reviewed
@@ -82,19 +91,22 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
           metricType={MetricType.Percent}
           value={hotspotsReviewedMeasure}
         />
-        <span className="sw-ml-1 sw-body-sm">
+
+        <span className="sw-body-sm sw-ml-1">
           {translate('metric.security_hotspots_reviewed.name')}
         </span>
+
         <HelpTooltip className="sw-ml-1" overlay={translate('hotspots.reviewed.tooltip')}>
           <HelperHintIcon aria-label="help-tooltip" />
         </HelpTooltip>
 
         {!isStaticListOfHotspots && (isBranch(branchLike) || userLoggedIn || isFiltered) && (
-          <div className="sw-flex-grow sw-flex sw-justify-end">
+          <div className="sw-flex sw-flex-grow sw-justify-end">
             <Dropdown
               allowResizing
               closeOnClick={false}
               id="filter-hotspots-menu"
+              isPortal
               overlay={
                 <>
                   <ItemHeader>{translate('hotspot.filters.title')}</ItemHeader>
@@ -113,14 +125,21 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
                   )}
 
                   {userLoggedIn && (
-                    <ItemCheckbox
-                      checked={Boolean(filters.assignedToMe)}
-                      onCheck={(assignedToMe: boolean) => props.onChangeFilters({ assignedToMe })}
+                    <Tooltip
+                      classNameSpace={component?.needIssueSync ? 'tooltip' : 'sw-hidden'}
+                      overlay={<HotspotDisabledFilterTooltip />}
+                      placement="right"
                     >
-                      <span className="sw-mx-2">
-                        {translate('hotspot.filters.assignee.assigned_to_me')}
-                      </span>
-                    </ItemCheckbox>
+                      <ItemCheckbox
+                        checked={Boolean(filters.assignedToMe)}
+                        disabled={component?.needIssueSync}
+                        onCheck={(assignedToMe: boolean) => props.onChangeFilters({ assignedToMe })}
+                      >
+                        <span className="sw-mx-2">
+                          {translate('hotspot.filters.assignee.assigned_to_me')}
+                        </span>
+                      </ItemCheckbox>
+                    </Tooltip>
                   )}
 
                   {isFiltered && <ItemDivider />}
@@ -140,7 +159,6 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
                 </>
               }
               placement={PopupPlacement.BottomRight}
-              isPortal
             >
               <DiscreetInteractiveIcon
                 Icon={FilterIcon}
@@ -156,4 +174,4 @@ function HotspotSidebarHeader(props: SecurityHotspotsAppRendererProps) {
   );
 }
 
-export default withCurrentUserContext(HotspotSidebarHeader);
+export default withComponentContext(withCurrentUserContext(HotspotSidebarHeader));
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/HotspotDisabledFilterTooltip-test.tsx
new file mode 100644 (file)
index 0000000..363e3a8
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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 { createEvent, fireEvent, render as rtlRender, screen } from '@testing-library/react';
+import * as React from 'react';
+import { HotspotDisabledFilterTooltip } from '../HotspotDisabledFilterTooltip';
+
+it('should render correctly and stop event propagation', () => {
+  const { container } = rtlRender(<HotspotDisabledFilterTooltip />);
+
+  expect(container).toMatchSnapshot();
+
+  const reindexingLink = screen.getByText('indexation.reindexing');
+
+  const mouseDownEvent = createEvent.mouseDown(reindexingLink);
+
+  fireEvent(reindexingLink, mouseDownEvent);
+
+  expect(mouseDownEvent.defaultPrevented).toBe(true);
+});
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap b/server/sonar-web/src/main/js/apps/security-hotspots/components/__tests__/__snapshots__/HotspotDisabledFilterTooltip-test.tsx.snap
new file mode 100644 (file)
index 0000000..baba3c9
--- /dev/null
@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly and stop event propagation 1`] = `
+<div>
+  <div
+    class="sw-body-sm sw-w-[190px]"
+  >
+    <p>
+      indexation.page_unavailable.description
+       
+      indexation.filter_unavailable.description
+    </p>
+    <hr
+      class="sw-mx-0 sw-my-3 sw-p-0 sw-w-full"
+    />
+    <span
+      class="sw-body-sm-highlight"
+    >
+      indexation.learn_more
+    </span>
+    <a
+      class="sw-ml-1"
+      href="https://docs.sonarqube.org/latest/instance-administration/reindexing/"
+      rel="noopener noreferrer"
+      target="_blank"
+    >
+      <svg
+        class="little-spacer-right"
+        height="14"
+        style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 1.41421;"
+        version="1.1"
+        viewBox="0 0 16 16"
+        width="14"
+        xml:space="preserve"
+        xmlns:xlink="http://www.w3.org/1999/xlink"
+      >
+        <title>
+          opens_in_new_window
+        </title>
+        <path
+          d="M12 9.25v2.5A2.25 2.25 0 0 1 9.75 14h-6.5A2.25 2.25 0 0 1 1 11.75v-6.5A2.25 2.25 0 0 1 3.25 3h5.5c.14 0 .25.11.25.25v.5c0 .14-.11.25-.25.25h-5.5C2.562 4 2 4.563 2 5.25v6.5c0 .688.563 1.25 1.25 1.25h6.5c.688 0 1.25-.563 1.25-1.25v-2.5c0-.14.11-.25.25-.25h.5c.14 0 .25.11.25.25zm3-6.75v4c0 .273-.227.5-.5.5a.497.497 0 0 1-.352-.148l-1.375-1.375L7.68 10.57a.27.27 0 0 1-.18.078.27.27 0 0 1-.18-.078l-.89-.89a.27.27 0 0 1-.078-.18.27.27 0 0 1 .078-.18l5.093-5.093-1.375-1.375A.497.497 0 0 1 10 2.5c0-.273.227-.5.5-.5h4c.273 0 .5.227.5.5z"
+          style="fill: currentColor;"
+        />
+      </svg>
+      indexation.reindexing
+    </a>
+  </div>
+</div>
+`;
index 55d2815798201c0153edf86f1fe0f997e547614f..736bdf495c95bc658e1dd23ebe7c16e06930a377 100644 (file)
@@ -4735,7 +4735,9 @@ indexation.page_unavailable.title.portfolios=Portfolios page is temporarily unav
 indexation.page_unavailable.title={componentQualifier} {componentName} is temporarily unavailable
 indexation.page_unavailable.description=SonarQube is reindexing project data.
 indexation.page_unavailable.description.additional_information=This page is unavailable until this process is complete. {link}
-
+indexation.filter_unavailable.description=This filter is unavailable until this process is complete.
+indexation.learn_more=Learn more:
+indexation.reindexing=Reindexing
 
 #------------------------------------------------------------------------------
 #