]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19345 Issues page - list view: new facets using MIUI elements
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Tue, 30 May 2023 14:00:31 +0000 (16:00 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 9 Jun 2023 20:03:09 +0000 (20:03 +0000)
60 files changed:
server/sonar-web/design-system/src/components/BarChart.tsx
server/sonar-web/design-system/src/components/DatePicker.tsx
server/sonar-web/design-system/src/components/DateRangePicker.tsx
server/sonar-web/design-system/src/components/FacetBox.tsx
server/sonar-web/design-system/src/components/FacetItem.tsx
server/sonar-web/design-system/src/components/FlagMessage.tsx
server/sonar-web/design-system/src/components/InputSearch.tsx
server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx
server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx
server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx
server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx
server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx
server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap
server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap
server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/PeriodFilter.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.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/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/components/facet/FacetItem.tsx
server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
server/sonar-web/src/main/js/components/search-navigator.css
server/sonar-web/src/main/js/helpers/constants.ts
server/sonar-web/src/main/js/helpers/mocks/issues.ts
server/sonar-web/tailwind-utilities.js
server/sonar-web/tailwind.base.config.js
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 9b460674e69a6caf5c3be0cba97bde2131280330..91a01174c97bf27cda54df0ee493ae4c45a9c3ba 100644 (file)
@@ -24,7 +24,7 @@ import { themeColor } from '../helpers';
 
 interface DataPoint {
   description: string;
-  tooltip?: string;
+  tooltip?: string | JSX.Element;
   x: number;
   y: number;
 }
index e80795cbad244b211f70f7820db52e7daa1d33c4..0bb34725c2d489e9dc629328a3480e29c8abf671 100644 (file)
@@ -17,8 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import styled from '@emotion/styled';
 
+import styled from '@emotion/styled';
 import classNames from 'classnames';
 import {
   format,
@@ -76,7 +76,7 @@ interface Props {
   showClearButton?: boolean;
   size?: InputSizeKeys;
   value?: Date;
-  valueFormatter: (date?: Date) => string;
+  valueFormatter?: (date?: Date) => string;
 }
 
 interface State {
@@ -142,6 +142,7 @@ export class DatePicker extends React.PureComponent<Props, State> {
       id,
       placeholder,
       showClearButton = true,
+      valueFormatter = (date?: Date) => (date ? format(date, 'MMM d, yyyy') : ''),
       size,
     } = this.props;
     const { lastHovered, currentMonth, open } = this.state;
@@ -153,10 +154,12 @@ export class DatePicker extends React.PureComponent<Props, State> {
     const selectedDays = selectedDay ? [selectedDay] : [];
     let highlighted: Matcher = false;
     const lastHoveredOrValue = lastHovered ?? selectedDay;
+
     if (highlightFrom && lastHoveredOrValue) {
       highlighted = { from: highlightFrom, to: lastHoveredOrValue };
       selectedDays.push(highlightFrom);
     }
+
     if (highlightTo && lastHoveredOrValue) {
       highlighted = { from: lastHoveredOrValue, to: highlightTo };
       selectedDays.push(highlightTo);
@@ -221,11 +224,13 @@ export class DatePicker extends React.PureComponent<Props, State> {
                   readOnly
                   ref={inputRef}
                   size={size}
-                  title={this.props.valueFormatter(selectedDay)}
+                  title={valueFormatter(selectedDay)}
                   type="text"
-                  value={this.props.valueFormatter(selectedDay)}
+                  value={valueFormatter(selectedDay)}
                 />
+
                 <StyledCalendarIcon fill="datePickerIcon" />
+
                 {selectedDay !== undefined && showClearButton && (
                   <StyledInteractiveIcon
                     Icon={CloseIcon}
@@ -327,16 +332,21 @@ function getCustomCalendarNavigation({
     const { goToMonth, nextMonth, previousMonth } = useCalendarNavigation();
 
     const baseDate = startOfMonth(displayMonth); // reference date
+
     const months = range(MONTHS_IN_A_YEAR).map((month) => {
       const monthValue = setMonth(baseDate, month);
+
       return {
         label: format(monthValue, 'MMM'),
         value: monthValue,
       };
     });
+
     const startYear = fromYear ?? getYear(Date.now()) - YEARS_TO_DISPLAY;
+
     const years = range(startYear, toYear ? toYear + 1 : undefined).map((year) => {
       const yearValue = setYear(baseDate, year);
+
       return {
         label: String(year),
         value: yearValue,
@@ -349,37 +359,53 @@ function getCustomCalendarNavigation({
           Icon={ChevronLeftIcon}
           aria-label={ariaPreviousMonthLabel}
           className="sw-mr-2"
-          onClick={() => previousMonth && goToMonth(previousMonth)}
-          size="small"
-        />
-        <InputSelect
-          isClearable={false}
-          onChange={(value) => {
-            if (value) {
-              goToMonth(value.value);
-            }
-          }}
-          options={months}
-          size="full"
-          value={months.find((m) => isSameMonth(m.value, displayMonth))}
-        />
-        <InputSelect
-          className="sw-ml-1"
-          isClearable={false}
-          onChange={(value) => {
-            if (value) {
-              goToMonth(value.value);
+          onClick={() => {
+            if (previousMonth) {
+              goToMonth(previousMonth);
             }
           }}
-          options={years}
-          size="full"
-          value={years.find((y) => isSameYear(y.value, displayMonth))}
+          size="small"
         />
+
+        <span data-testid="month-select">
+          <InputSelect
+            isClearable={false}
+            onChange={(value) => {
+              if (value) {
+                goToMonth(value.value);
+              }
+            }}
+            options={months}
+            size="full"
+            value={months.find((m) => isSameMonth(m.value, displayMonth))}
+          />
+        </span>
+
+        <span data-testid="year-select">
+          <InputSelect
+            className="sw-ml-1"
+            data-testid="year-select"
+            isClearable={false}
+            onChange={(value) => {
+              if (value) {
+                goToMonth(value.value);
+              }
+            }}
+            options={years}
+            size="full"
+            value={years.find((y) => isSameYear(y.value, displayMonth))}
+          />
+        </span>
+
         <InteractiveIcon
           Icon={ChevronRightIcon}
           aria-label={ariaNextMonthLabel}
           className="sw-ml-2"
-          onClick={() => nextMonth && goToMonth(nextMonth)}
+          onClick={() => {
+            if (nextMonth) {
+              goToMonth(nextMonth);
+            }
+          }}
           size="small"
         />
       </nav>
index 132deafcd04aad16b285ed2c34d365fa754da1d3..01e73b17baf52e67eb17c2c98b538b77575e6dd3 100644 (file)
@@ -41,7 +41,7 @@ interface Props {
   separatorText?: string;
   toLabel: string;
   value?: DateRange;
-  valueFormatter: (date?: Date) => string;
+  valueFormatter?: (date?: Date) => string;
 }
 
 export class DateRangePicker extends React.PureComponent<Props> {
index 2e100268542a3ee0eada63d3e6111ccf7903bb8b..0366e6fe69ef51e217b42c4c9d3672098b453295 100644 (file)
@@ -26,7 +26,7 @@ import tw from 'twin.macro';
 import { themeColor } from '../helpers';
 import { Badge } from './Badge';
 import { DeferredSpinner } from './DeferredSpinner';
-import { InteractiveIcon } from './InteractiveIcon';
+import { DestructiveIcon } from './InteractiveIcon';
 import Tooltip from './Tooltip';
 import { BareButton } from './buttons';
 import { OpenCloseIndicator } from './icons';
@@ -39,7 +39,9 @@ export interface FacetBoxProps {
   clearIconLabel?: string;
   count?: number;
   countLabel?: string;
+  'data-property'?: string;
   disabled?: boolean;
+  hasEmbeddedFacets?: boolean;
   id?: string;
   inner?: boolean;
   loading?: boolean;
@@ -57,7 +59,9 @@ export function FacetBox(props: FacetBoxProps) {
     clearIconLabel,
     count,
     countLabel,
+    'data-property': dataProperty,
     disabled = false,
+    hasEmbeddedFacets = false,
     id: idProp,
     inner = false,
     loading = false,
@@ -73,7 +77,13 @@ export function FacetBox(props: FacetBoxProps) {
   const id = React.useMemo(() => idProp ?? uniqueId('filter-facet-'), [idProp]);
 
   return (
-    <Accordion className={classNames(className, { open })} inner={inner} role="listitem">
+    <Accordion
+      className={classNames(className, { open })}
+      data-property={dataProperty}
+      hasEmbeddedFacets={hasEmbeddedFacets}
+      inner={inner}
+      role="listitem"
+    >
       <Header>
         <ChevronAndTitle
           aria-controls={`${id}-panel`}
@@ -106,6 +116,7 @@ export function FacetBox(props: FacetBoxProps) {
                 <ClearIcon
                   Icon={CloseIcon}
                   aria-label={clearIconLabel ?? ''}
+                  data-testid={`clear-${name}`}
                   onClick={onClear}
                   size="small"
                 />
@@ -116,7 +127,7 @@ export function FacetBox(props: FacetBoxProps) {
       </Header>
 
       {open && (
-        <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="region">
+        <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="list">
           {children}
         </div>
       )}
@@ -124,14 +135,19 @@ export function FacetBox(props: FacetBoxProps) {
   );
 }
 
+FacetBox.displayName = 'FacetBox'; // so that tests don't see the obfuscated production name
+
 const Accordion = styled.div<{
+  hasEmbeddedFacets?: boolean;
   inner?: boolean;
 }>`
   ${tw`sw-flex-col`};
   ${tw`sw-flex`};
   ${tw`sw-gap-3`};
 
-  ${({ inner }) => (inner ? tw`sw-gap-1 sw-ml-3` : '')};
+  ${({ hasEmbeddedFacets }) => (hasEmbeddedFacets ? tw`sw-gap-0` : '')};
+
+  ${({ inner }) => (inner ? tw`sw-gap-1 sw-ml-3 sw-mt-1` : '')};
 `;
 
 const BadgeAndIcons = styled.div`
@@ -150,7 +166,7 @@ const ChevronAndTitle = styled(BareButton)<{
   cursor: ${({ expandable }) => (expandable ? 'pointer' : 'default')};
 `;
 
-const ClearIcon = styled(InteractiveIcon)`
+const ClearIcon = styled(DestructiveIcon)`
   --color: ${themeColor('dangerButton')};
 `;
 
index 0d318feec0a0c7091511d2a7c2ff7dc666d4dcf0..62d5e38b150d24fa525a7e8386b394fa8b7d489a 100644 (file)
@@ -26,8 +26,9 @@ import { ButtonProps, ButtonSecondary } from './buttons';
 
 export type FacetItemProps = Omit<ButtonProps, 'name' | 'onClick'> & {
   active?: boolean;
-  name: string;
+  name: string | React.ReactNode;
   onClick: (x: string, multiple?: boolean) => void;
+  small?: boolean;
   stat?: React.ReactNode;
   /** Textual version of `name` */
   tooltip?: string;
@@ -41,11 +42,12 @@ export function FacetItem({
   icon,
   name,
   onClick,
+  small,
   stat,
   tooltip,
   value,
 }: FacetItemProps) {
-  const disabled = disabledProp || (stat as number) === 0;
+  const disabled = disabledProp || (stat !== undefined && stat === 0);
 
   const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault();
@@ -56,12 +58,15 @@ export function FacetItem({
   return (
     <StyledButton
       active={active}
+      aria-checked={active}
+      aria-label={typeof name === 'string' ? name : undefined}
       className={className}
       data-facet={value}
       disabled={disabled}
       icon={icon}
       onClick={handleClick}
-      role="listitem"
+      role="checkbox"
+      small={small}
       title={tooltip}
     >
       <span className="container">
@@ -72,10 +77,17 @@ export function FacetItem({
   );
 }
 
-const StyledButton = styled(ButtonSecondary)<{ active?: boolean }>`
+FacetItem.displayName = 'FacetItem'; // so that tests don't see the obfuscated production name
+
+const StyledButton = styled(ButtonSecondary)<{ active?: boolean; small?: boolean }>`
   ${tw`sw-body-sm`};
-  ${tw`sw-p-1`};
+  ${tw`sw-box-border`};
+  ${tw`sw-h-7`};
+  ${tw`sw-px-1`};
   ${tw`sw-rounded-1`};
+  ${tw`sw-w-full`};
+
+  ${({ small }) => (small ? tw`sw-body-xs sw-pr-0` : '')};
 
   --background: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')};
   --backgroundHover: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')};
@@ -95,6 +107,15 @@ const StyledButton = styled(ButtonSecondary)<{ active?: boolean }>`
     ${tw`sw-items-center`};
     ${tw`sw-justify-between`};
 
+    & span.name {
+      ${tw`sw-pr-1`};
+      ${tw`sw-truncate`};
+
+      & mark {
+        background-color: ${themeColor('searchHighlight')};
+      }
+    }
+
     & span.stat {
       color: ${themeColor('facetItemLight')};
     }
index 3a3aed03c94260555439ce452ad8d6eeabb30181..72ef3a9808ffa78ec60accd44fe88ae910f5d173 100644 (file)
@@ -90,6 +90,8 @@ export function FlagMessage(props: Props & React.HTMLAttributes<HTMLDivElement>)
   );
 }
 
+FlagMessage.displayName = 'FlagMessage'; // so that tests don't see the obfuscated production name
+
 export const StyledFlag = styled.div<{
   variantInfo: VariantInformation;
 }>`
index 3c0233ef392b19f5a6110752ce33635e83fb797a..354fab3b5d28c1ea9d73474ba527497e3864a174 100644 (file)
@@ -164,7 +164,7 @@ export function InputSearch({
           <StyledInteractiveIcon
             Icon={CloseIcon}
             aria-label={clearIconAriaLabel}
-            className="js-input-search-clear"
+            className="it__search-box-clear"
             onClick={handleClearClick}
             size="small"
           />
@@ -180,6 +180,8 @@ export function InputSearch({
   );
 }
 
+InputSearch.displayName = 'InputSearch'; // so that tests don't see the obfuscated production name
+
 export const InputSearchWrapper = styled.div`
   width: var(--inputSize);
 
index 7aa37f175d3d3d980412d95ea3cc9564a4ac55ba..230a5dc7231eee3dd6dc7c6788657f7630bcc93d 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import styled from '@emotion/styled';
 import tw from 'twin.macro';
 import { themeColor, themeContrast } from '../helpers';
 import { Key } from '../helpers/keyboard';
 import { TriangleDownIcon, TriangleLeftIcon, TriangleRightIcon, TriangleUpIcon } from './icons';
 
-const COMMAND = '⌘';
-const CTRL = 'Ctrl';
-const OPTION = '⌥';
-const ALT = 'Alt';
-const NON_KEY_SYMBOLS = ['+', ' '];
+export const mappedKeys = {
+  [Key.Alt]: 'Alt',
+  [Key.ArrowDown]: <TriangleDownIcon />,
+  [Key.ArrowLeft]: <TriangleLeftIcon />,
+  [Key.ArrowRight]: <TriangleRightIcon />,
+  [Key.ArrowUp]: <TriangleUpIcon />,
+  [Key.Command]: '⌘',
+  [Key.Control]: 'Ctrl',
+  [Key.Option]: '⌥',
+};
 
 export function KeyboardHintKeys({ command }: { command: string }) {
   const keys = command
@@ -35,11 +41,12 @@ export function KeyboardHintKeys({ command }: { command: string }) {
     .split(' ')
     .map((key, index) => {
       const uniqueKey = `${key}-${index}`;
-      if (NON_KEY_SYMBOLS.includes(key)) {
+
+      if (!(Object.keys(mappedKeys).includes(key) || Object.values(mappedKeys).includes(key))) {
         return <span key={uniqueKey}>{key}</span>;
       }
 
-      return <KeyBox key={uniqueKey}>{getKey(key)}</KeyBox>;
+      return <KeyBox key={uniqueKey}>{mappedKeys[key as keyof typeof mappedKeys] || key}</KeyBox>;
     });
 
   return <div className="sw-flex sw-gap-1">{keys}</div>;
@@ -50,29 +57,6 @@ export const KeyBox = styled.span`
   ${tw`sw-px-1/2`}
   ${tw`sw-rounded-1/2`}
 
-  color: ${themeContrast('keyboardHintKey')};
   background-color: ${themeColor('keyboardHintKey')};
+  color: ${themeContrast('keyboardHintKey')};
 `;
-
-function getKey(key: string) {
-  switch (key) {
-    case Key.Control:
-      return CTRL;
-    case Key.Command:
-      return COMMAND;
-    case Key.Alt:
-      return ALT;
-    case Key.Option:
-      return OPTION;
-    case Key.ArrowUp:
-      return <TriangleUpIcon />;
-    case Key.ArrowDown:
-      return <TriangleDownIcon />;
-    case Key.ArrowLeft:
-      return <TriangleLeftIcon />;
-    case Key.ArrowRight:
-      return <TriangleRightIcon />;
-    default:
-      return key;
-  }
-}
index 8edfa140e2a8fb69830ff86e4ad84b1ffe63863a..01aa68be4c7fe31e547f8e3ac17d0ba82291c517 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 { screen, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { getMonth, getYear, parseISO } from 'date-fns';
@@ -85,19 +86,46 @@ it('behaves correctly', async () => {
   expect(getYear(newDate3)).toBe(2019);
 });
 
-it('highlights the appropriate days', async () => {
+it('should clear the value', async () => {
   const user = userEvent.setup();
 
-  const value = parseISO('2022-06-14');
-  renderDatePicker({ highlightFrom: parseISO('2022-06-12'), showClearButton: true, value });
+  const onChange = jest.fn((_: Date) => undefined);
+
+  const currentDate = parseISO('2022-06-13');
+
+  renderDatePicker({
+    currentMonth: currentDate,
+    onChange,
+    showClearButton: true,
+    value: currentDate,
+    // eslint-disable-next-line jest/no-conditional-in-test
+    valueFormatter: (date?: Date) => (date ? 'formatted date' : 'no date'),
+  });
+
+  await user.click(screen.getByRole('textbox'));
+
+  await user.click(screen.getByLabelText('clear'));
+
+  expect(onChange).toHaveBeenCalledWith(undefined);
+});
+
+it.each([
+  [{ highlightFrom: parseISO('2022-06-12'), value: parseISO('2022-06-14') }],
+  [{ alignRight: true, highlightTo: parseISO('2022-06-14'), value: parseISO('2022-06-12') }],
+])('highlights the appropriate days', async (props) => {
+  const user = userEvent.setup();
+
+  const hightlightClass = 'rdp-highlighted';
+
+  renderDatePicker(props);
 
   await user.click(screen.getByRole('textbox'));
 
-  expect(screen.getByText('11')).not.toHaveClass('rdp-highlighted');
-  expect(screen.getByText('12')).toHaveClass('rdp-highlighted');
-  expect(screen.getByText('13')).toHaveClass('rdp-highlighted');
-  expect(screen.getByText('14')).toHaveClass('rdp-highlighted');
-  expect(screen.getByText('15')).not.toHaveClass('rdp-highlighted');
+  expect(screen.getByText('11')).not.toHaveClass(hightlightClass);
+  expect(screen.getByText('12')).toHaveClass(hightlightClass);
+  expect(screen.getByText('13')).toHaveClass(hightlightClass);
+  expect(screen.getByText('14')).toHaveClass(hightlightClass);
+  expect(screen.getByText('15')).not.toHaveClass(hightlightClass);
 });
 
 function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
index 88dd949988509909ceb4f834c30b6eb1d7e699b7..434a6147e5ca65f4b284b6393c64a684505a4679 100644 (file)
@@ -28,11 +28,11 @@ it('should render an empty disabled facet box', async () => {
 
   const onClick = jest.fn();
 
-  renderComponent({ disabled: true, onClick });
+  renderComponent({ disabled: true, hasEmbeddedFacets: true, onClick });
 
   expect(screen.getByRole('listitem')).toBeInTheDocument();
 
-  expect(screen.queryByRole('region')).not.toBeInTheDocument();
+  expect(screen.queryByRole('list')).not.toBeInTheDocument();
 
   expect(screen.getByText('Test FacetBox')).toBeInTheDocument();
 
@@ -58,7 +58,7 @@ it('should render an inner expanded facet box with count', async () => {
     open: true,
   });
 
-  expect(screen.getByRole('region')).toBeInTheDocument();
+  expect(screen.getByRole('list')).toBeInTheDocument();
 
   expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
 
index 12703d37f00965db50383a4150757cc696926795..a916181172ad0c2d71fef624350c8aa7798341db 100644 (file)
@@ -30,9 +30,9 @@ it('should render a disabled facet item', async () => {
 
   renderComponent({ disabled: true, onClick });
 
-  expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'true');
+  expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true');
 
-  await user.click(screen.getByRole('listitem'));
+  await user.click(screen.getByRole('checkbox'));
 
   expect(onClick).not.toHaveBeenCalled();
 });
@@ -44,18 +44,30 @@ it('should render a non-disabled facet item', async () => {
 
   renderComponent({ active: true, onClick, stat: 3, value: 'foo' });
 
-  expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'false');
+  expect(screen.getByRole('checkbox')).toHaveAttribute('aria-disabled', 'false');
 
-  await user.click(screen.getByRole('listitem'));
+  await user.click(screen.getByRole('checkbox'));
 
   expect(onClick).toHaveBeenCalledWith('foo', false);
 
   await user.keyboard('{Meta>}');
-  await user.click(screen.getByRole('listitem'));
+  await user.click(screen.getByRole('checkbox'));
 
   expect(onClick).toHaveBeenLastCalledWith('foo', true);
 });
 
+it('should add an aria label if the name is a string', () => {
+  renderComponent({ name: 'Foo' });
+
+  expect(screen.getByRole('checkbox')).toHaveAccessibleName('Foo');
+});
+
+it('should not add an aria label if the name is not a string', () => {
+  renderComponent({ name: <div>Foo</div>, small: true });
+
+  expect(screen.getByRole('checkbox')).not.toHaveAttribute('aria-label');
+});
+
 function renderComponent(props: Partial<FacetItemProps> = {}) {
   return render(<FacetItem name="Test facet item" onClick={jest.fn()} value="Value" {...props} />);
 }
index 4d1ff44cede652654768fd67a0bc139df65f1d5f..b26ee4e28011962de014f5e1bb3a7ba8b6c3c942 100644 (file)
 import { Key } from '../../helpers/keyboard';
 import { render } from '../../helpers/testUtils';
 import { FCProps } from '../../types/misc';
-import { KeyboardHintKeys } from '../KeyboardHintKeys';
+import { KeyboardHintKeys, mappedKeys } from '../KeyboardHintKeys';
 
-it.each([
-  Key.Control,
-  Key.Command,
-  Key.Alt,
-  Key.Option,
-  Key.ArrowUp,
-  Key.ArrowDown,
-  Key.ArrowLeft,
-  Key.ArrowRight,
-])('should render %s', (key) => {
+it.each(Object.keys(mappedKeys))('should render %s', (key) => {
   const { container } = setupWithProps({ command: key });
   expect(container).toMatchSnapshot();
 });
 
 it('should render multiple keys', () => {
-  const { container } = setupWithProps({ command: `${Key.ArrowUp} ${Key.ArrowDown}` });
+  const { container } = setupWithProps({ command: `Use Ctrl + ${Key.ArrowUp} ${Key.ArrowDown}` });
   expect(container).toMatchSnapshot();
 });
 
index 081af387a407eea75e0cd6eeaa2f35c12e689637..6ac0d945eefb9e7f294112c0d4797251bf4c42e0 100644 (file)
@@ -34,8 +34,8 @@ exports[`renders on mac 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -94,8 +94,8 @@ exports[`renders on windows 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -138,26 +138,6 @@ exports[`renders with command 1`] = `
   color: rgb(106,117,144);
 }
 
-.emotion-2 {
-  display: -webkit-box;
-  display: -webkit-flex;
-  display: -ms-flexbox;
-  display: flex;
-  -webkit-align-items: center;
-  -webkit-box-align: center;
-  -ms-flex-align: center;
-  align-items: center;
-  -webkit-box-pack: center;
-  -ms-flex-pack: center;
-  -webkit-justify-content: center;
-  justify-content: center;
-  padding-left: 0.125rem;
-  padding-right: 0.125rem;
-  border-radius: 0.125rem;
-  color: rgb(62,67,87);
-  background-color: rgb(225,230,243);
-}
-
 <div>
   <div
     class="emotion-0 emotion-1"
@@ -165,9 +145,7 @@ exports[`renders with command 1`] = `
     <div
       class="sw-flex sw-gap-1"
     >
-      <span
-        class="emotion-2 emotion-3"
-      >
+      <span>
         command
       </span>
     </div>
@@ -193,26 +171,6 @@ exports[`renders with title 1`] = `
   color: rgb(106,117,144);
 }
 
-.emotion-2 {
-  display: -webkit-box;
-  display: -webkit-flex;
-  display: -ms-flexbox;
-  display: flex;
-  -webkit-align-items: center;
-  -webkit-box-align: center;
-  -ms-flex-align: center;
-  align-items: center;
-  -webkit-box-pack: center;
-  -ms-flex-pack: center;
-  -webkit-justify-content: center;
-  justify-content: center;
-  padding-left: 0.125rem;
-  padding-right: 0.125rem;
-  border-radius: 0.125rem;
-  color: rgb(62,67,87);
-  background-color: rgb(225,230,243);
-}
-
 <div>
   <div
     class="emotion-0 emotion-1"
@@ -225,9 +183,7 @@ exports[`renders with title 1`] = `
     <div
       class="sw-flex sw-gap-1"
     >
-      <span
-        class="emotion-2 emotion-3"
-      >
+      <span>
         click
       </span>
     </div>
@@ -253,26 +209,6 @@ exports[`renders without title 1`] = `
   color: rgb(106,117,144);
 }
 
-.emotion-2 {
-  display: -webkit-box;
-  display: -webkit-flex;
-  display: -ms-flexbox;
-  display: flex;
-  -webkit-align-items: center;
-  -webkit-box-align: center;
-  -ms-flex-align: center;
-  align-items: center;
-  -webkit-box-pack: center;
-  -ms-flex-pack: center;
-  -webkit-justify-content: center;
-  justify-content: center;
-  padding-left: 0.125rem;
-  padding-right: 0.125rem;
-  border-radius: 0.125rem;
-  color: rgb(62,67,87);
-  background-color: rgb(225,230,243);
-}
-
 <div>
   <div
     class="emotion-0 emotion-1"
@@ -280,9 +216,7 @@ exports[`renders without title 1`] = `
     <div
       class="sw-flex sw-gap-1"
     >
-      <span
-        class="emotion-2 emotion-3"
-      >
+      <span>
         click
       </span>
     </div>
index 907e3994ef0c63ad6562e8e632aa4ce0bd1398f6..be6c83072f5bead69180bbde99ff2030a63fee63 100644 (file)
@@ -17,8 +17,8 @@ exports[`should render Alt 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -51,8 +51,8 @@ exports[`should render ArrowDown 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -99,8 +99,8 @@ exports[`should render ArrowLeft 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -147,8 +147,8 @@ exports[`should render ArrowRight 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -195,8 +195,8 @@ exports[`should render ArrowUp 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -243,8 +243,8 @@ exports[`should render Command 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -277,8 +277,8 @@ exports[`should render Control 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -311,8 +311,8 @@ exports[`should render Option 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -345,8 +345,8 @@ exports[`should render a default text if no keys match 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
@@ -361,9 +361,7 @@ exports[`should render a default text if no keys match 1`] = `
     <span>
       +
     </span>
-    <span
-      class="emotion-0 emotion-1"
-    >
+    <span>
       click
     </span>
   </div>
@@ -387,14 +385,25 @@ exports[`should render multiple keys 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
   <div
     class="sw-flex sw-gap-1"
   >
+    <span>
+      Use
+    </span>
+    <span
+      class="emotion-0 emotion-1"
+    >
+      Ctrl
+    </span>
+    <span>
+      +
+    </span>
     <span
       class="emotion-0 emotion-1"
     >
@@ -454,8 +463,8 @@ exports[`should render multiple keys with non-key symbols 1`] = `
   padding-left: 0.125rem;
   padding-right: 0.125rem;
   border-radius: 0.125rem;
-  color: rgb(62,67,87);
   background-color: rgb(225,230,243);
+  color: rgb(62,67,87);
 }
 
 <div>
index fae7278a2f6456b91cbb03ac0cac8afd5bf73ca3..9ab29f18b3708a82252e9c15e48daa509f0317c2 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 { useTheme } from '@emotion/react';
 import { themeColor } from '../../helpers/theme';
 import { CustomIcon, IconProps } from './Icon';
@@ -24,6 +25,7 @@ import { CustomIcon, IconProps } from './Icon';
 export function TestFileIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
   const theme = useTheme();
   const fillColor = themeColor(fill)({ theme });
+
   return (
     <CustomIcon {...iconProps}>
       <path
index d81089a367a90d35d5e93d2d59df8fc1d51bf6d0..898e9a46890923a7a3169a2c51aacec82c6628df 100644 (file)
@@ -70,6 +70,7 @@ export { StatusConfirmedIcon } from './StatusConfirmedIcon';
 export { StatusOpenIcon } from './StatusOpenIcon';
 export { StatusReopenedIcon } from './StatusReopenedIcon';
 export { StatusResolvedIcon } from './StatusResolvedIcon';
+export { TestFileIcon } from './TestFileIcon';
 export { TrashIcon } from './TrashIcon';
 export { TriangleDownIcon } from './TriangleDownIcon';
 export { TriangleLeftIcon } from './TriangleLeftIcon';
index 2752252946a4fc59a6a927cfe043db1dcbbb438d..080d3b941e9f0cf226f0abeb097b94adc1fb72a8 100644 (file)
@@ -19,7 +19,9 @@
  */
 import { cloneDeep, uniqueId } from 'lodash';
 import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
-import { mockIssueChangelog } from '../../helpers/mocks/issues';
+
+import { RESOLUTIONS, SEVERITIES, SOURCE_SCOPES, STATUSES } from '../../helpers/constants';
+import { mockIssueAuthors, mockIssueChangelog } from '../../helpers/mocks/issues';
 import { RequestData } from '../../helpers/request';
 import { getStandards } from '../../helpers/security-standard';
 import { mockLoggedInUser, mockPaging, mockRuleDetails } from '../../helpers/testMocks';
@@ -35,10 +37,11 @@ import {
   RawIssuesResponse,
   ReferencedComponent,
 } from '../../types/issues';
+import { MetricKey } from '../../types/metrics';
 import { SearchRulesQuery } from '../../types/rules';
 import { Standards } from '../../types/security';
 import { Dict, Rule, RuleActivation, RuleDetails, SnippetsByComponent } from '../../types/types';
-import { LoggedInUser, NoticeType } from '../../types/users';
+import { LoggedInUser, NoticeType, User } from '../../types/users';
 import {
   addIssueComment,
   bulkChangeIssues,
@@ -46,6 +49,7 @@ import {
   editIssueComment,
   getIssueChangelog,
   getIssueFlowSnippets,
+  searchIssueAuthors,
   searchIssueTags,
   searchIssues,
   setIssueAssignee,
@@ -103,24 +107,25 @@ export default class IssuesServiceMock {
 
     this.list = cloneDeep(this.defaultList);
 
-    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);
-    (bulkChangeIssues as jest.Mock).mockImplementation(this.handleBulkChangeIssues);
-    (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
-    (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
-    (setIssueType as jest.Mock).mockImplementation(this.handleSetIssueType);
-    jest.mocked(setIssueAssignee).mockImplementation(this.handleSetIssueAssignee);
-    (setIssueSeverity as jest.Mock).mockImplementation(this.handleSetIssueSeverity);
-    (setIssueTransition as jest.Mock).mockImplementation(this.handleSetIssueTransition);
-    (setIssueTags as jest.Mock).mockImplementation(this.handleSetIssueTags);
     jest.mocked(addIssueComment).mockImplementation(this.handleAddComment);
-    jest.mocked(editIssueComment).mockImplementation(this.handleEditComment);
+    jest.mocked(bulkChangeIssues).mockImplementation(this.handleBulkChangeIssues);
     jest.mocked(deleteIssueComment).mockImplementation(this.handleDeleteComment);
-    (searchUsers as jest.Mock).mockImplementation(this.handleSearchUsers);
-    (searchIssueTags as jest.Mock).mockImplementation(this.handleSearchIssueTags);
+    jest.mocked(dismissNotice).mockImplementation(this.handleDismissNotification);
+    jest.mocked(editIssueComment).mockImplementation(this.handleEditComment);
+    jest.mocked(getCurrentUser).mockImplementation(this.handleGetCurrentUser);
     jest.mocked(getIssueChangelog).mockImplementation(this.handleGetIssueChangelog);
+    jest.mocked(getIssueFlowSnippets).mockImplementation(this.handleGetIssueFlowSnippets);
+    jest.mocked(getRuleDetails).mockImplementation(this.handleGetRuleDetails);
+    jest.mocked(searchIssueAuthors).mockImplementation(this.handleSearchIssueAuthors);
+    jest.mocked(searchIssues).mockImplementation(this.handleSearchIssues);
+    jest.mocked(searchIssueTags).mockImplementation(this.handleSearchIssueTags);
+    jest.mocked(searchRules).mockImplementation(this.handleSearchRules);
+    jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers);
+    jest.mocked(setIssueAssignee).mockImplementation(this.handleSetIssueAssignee);
+    jest.mocked(setIssueSeverity).mockImplementation(this.handleSetIssueSeverity);
+    jest.mocked(setIssueTags).mockImplementation(this.handleSetIssueTags);
+    jest.mocked(setIssueTransition).mockImplementation(this.handleSetIssueTransition);
+    jest.mocked(setIssueType).mockImplementation(this.handleSetIssueType);
   }
 
   reset = () => {
@@ -162,7 +167,7 @@ export default class IssuesServiceMock {
       .forEach((data) => {
         data.issue.type = query.set_type;
       });
-    return this.reply({});
+    return this.reply(undefined);
   };
 
   handleGetIssueFlowSnippets = (issueKey: string): Promise<Dict<SnippetsByComponent>> => {
@@ -274,6 +279,15 @@ export default class IssuesServiceMock {
           ],
         };
       }
+      if (name === 'scopes') {
+        return {
+          property: name,
+          values: SOURCE_SCOPES.map(({ scope }) => ({
+            val: scope,
+            count: 1, // if 0, the facet can't be clicked in tests
+          })),
+        };
+      }
       if (name === 'codeVariants') {
         return {
           property: 'codeVariants',
@@ -295,7 +309,7 @@ export default class IssuesServiceMock {
           }, [] as RawFacet['values']),
         };
       }
-      if (name === 'projects') {
+      if (name === MetricKey.projects) {
         return {
           property: name,
           values: [
@@ -354,7 +368,13 @@ export default class IssuesServiceMock {
       }
       return {
         property: name,
-        values: [],
+        values: (
+          { resolutions: RESOLUTIONS, severities: SEVERITIES, statuses: STATUSES, types }[name] ??
+          []
+        ).map((val) => ({
+          val,
+          count: 1, // if 0, the facet can't be clicked in tests
+        })),
       };
     });
   };
@@ -577,11 +597,15 @@ export default class IssuesServiceMock {
   };
 
   handleSearchUsers = () => {
-    return this.reply({ users: [mockLoggedInUser()] });
+    return this.reply({ paging: mockPaging(), users: [mockLoggedInUser() as unknown as User] });
+  };
+
+  handleSearchIssueAuthors = () => {
+    return this.reply(mockIssueAuthors());
   };
 
   handleSearchIssueTags = () => {
-    return this.reply(['accessibility', 'android']);
+    return this.reply(['accessibility', 'android', 'unused']);
   };
 
   handleGetIssueChangelog = (_issue: string) => {
index a9b52ce93e83006507c6c3385f257bd6843d5762..2d43646bba3d5017a4d3a260f42da39a9694945f 100644 (file)
@@ -20,7 +20,6 @@
 import * as React from 'react';
 import { Profile } from '../../../api/quality-profiles';
 import { Dict } from '../../../types/types';
-import StandardFacet from '../../issues/sidebar/StandardFacet';
 import { Facets, OpenFacets, Query } from '../query';
 import ActivationSeverityFacet from './ActivationSeverityFacet';
 import AvailableSinceFacet from './AvailableSinceFacet';
@@ -29,6 +28,7 @@ import InheritanceFacet from './InheritanceFacet';
 import LanguageFacet from './LanguageFacet';
 import ProfileFacet from './ProfileFacet';
 import RepositoryFacet from './RepositoryFacet';
+import { StandardFacet } from './StandardFacet';
 import StatusFacet from './StatusFacet';
 import TagFacet from './TagFacet';
 import TemplateFacet from './TemplateFacet';
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx
new file mode 100644 (file)
index 0000000..2e63ec9
--- /dev/null
@@ -0,0 +1,540 @@
+/*
+ * 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.
+ */
+/* eslint-disable react/no-unused-prop-types */
+
+import { omit, 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 ListStyleFacet from '../../../components/facet/ListStyleFacet';
+import ListStyleFacetFooter from '../../../components/facet/ListStyleFacetFooter';
+import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
+import { translate } from '../../../helpers/l10n';
+import { highlightTerm } from '../../../helpers/search';
+import {
+  getStandards,
+  renderCWECategory,
+  renderOwaspTop102021Category,
+  renderOwaspTop10Category,
+  renderSonarSourceSecurityCategory,
+} from '../../../helpers/security-standard';
+import { Facet } from '../../../types/issues';
+import { SecurityStandard, Standards } from '../../../types/security';
+import { Dict } from '../../../types/types';
+import { Query, STANDARDS, formatFacetStat } from '../../issues/utils';
+
+interface Props {
+  cwe: string[];
+  cweOpen: boolean;
+  cweStats: Dict<number> | undefined;
+  fetchingCwe: boolean;
+  fetchingOwaspTop10: boolean;
+  'fetchingOwaspTop10-2021': boolean;
+  fetchingSonarSourceSecurity: boolean;
+  loadSearchResultCount?: (property: string, changes: Partial<Query>) => Promise<Facet>;
+  onChange: (changes: Partial<Query>) => void;
+  onToggle: (property: string) => void;
+  open: boolean;
+  owaspTop10: string[];
+  owaspTop10Open: boolean;
+  owaspTop10Stats: Dict<number> | undefined;
+  'owaspTop10-2021': string[];
+  'owaspTop10-2021Open': boolean;
+  'owaspTop10-2021Stats': Dict<number> | undefined;
+  query: Partial<Query>;
+  sonarsourceSecurity: string[];
+  sonarsourceSecurityOpen: boolean;
+  sonarsourceSecurityStats: Dict<number> | undefined;
+}
+
+interface State {
+  standards: Standards;
+  showFullSonarSourceList: boolean;
+}
+
+type StatsProp =
+  | 'owaspTop10-2021Stats'
+  | 'owaspTop10Stats'
+  | 'cweStats'
+  | 'sonarsourceSecurityStats';
+type ValuesProp = 'owaspTop10-2021' | 'owaspTop10' | 'sonarsourceSecurity' | 'cwe';
+
+const INITIAL_FACET_COUNT = 15;
+export class StandardFacet extends React.PureComponent<Props, State> {
+  mounted = false;
+  property = STANDARDS;
+  state: State = {
+    showFullSonarSourceList: false,
+    standards: {
+      owaspTop10: {},
+      'owaspTop10-2021': {},
+      cwe: {},
+      sonarsourceSecurity: {},
+      'pciDss-3.2': {},
+      'pciDss-4.0': {},
+      'owaspAsvs-4.0': {},
+    },
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+
+    // load standards.json only if the facet is open, or there is a selected value
+    if (
+      this.props.open ||
+      this.props.owaspTop10.length > 0 ||
+      this.props['owaspTop10-2021'].length > 0 ||
+      this.props.cwe.length > 0 ||
+      this.props.sonarsourceSecurity.length > 0
+    ) {
+      this.loadStandards();
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (!prevProps.open && this.props.open) {
+      this.loadStandards();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  loadStandards = () => {
+    getStandards().then(
+      ({
+        'owaspTop10-2021': owaspTop102021,
+        owaspTop10,
+        cwe,
+        sonarsourceSecurity,
+        'pciDss-3.2': pciDss32,
+        'pciDss-4.0': pciDss40,
+        'owaspAsvs-4.0': owaspAsvs40,
+      }: Standards) => {
+        if (this.mounted) {
+          this.setState({
+            standards: {
+              'owaspTop10-2021': owaspTop102021,
+              owaspTop10,
+              cwe,
+              sonarsourceSecurity,
+              'pciDss-3.2': pciDss32,
+              'pciDss-4.0': pciDss40,
+              'owaspAsvs-4.0': owaspAsvs40,
+            },
+          });
+        }
+      },
+      () => {}
+    );
+  };
+
+  getValues = () => {
+    return [
+      ...this.props.sonarsourceSecurity.map((item) =>
+        renderSonarSourceSecurityCategory(this.state.standards, item, true)
+      ),
+
+      ...this.props.owaspTop10.map((item) =>
+        renderOwaspTop10Category(this.state.standards, item, true)
+      ),
+
+      ...this.props['owaspTop10-2021'].map((item) =>
+        renderOwaspTop102021Category(this.state.standards, item, true)
+      ),
+
+      ...this.props.cwe.map((item) => renderCWECategory(this.state.standards, item)),
+    ];
+  };
+
+  getFacetHeaderId = (property: string) => {
+    return `facet_${property}`;
+  };
+
+  handleHeaderClick = () => {
+    this.props.onToggle(this.property);
+  };
+
+  handleOwaspTop10HeaderClick = () => {
+    this.props.onToggle('owaspTop10');
+  };
+
+  handleOwaspTop102021HeaderClick = () => {
+    this.props.onToggle('owaspTop10-2021');
+  };
+
+  handleSonarSourceSecurityHeaderClick = () => {
+    this.props.onToggle('sonarsourceSecurity');
+  };
+
+  handleClear = () => {
+    this.props.onChange({
+      [this.property]: [],
+      owaspTop10: [],
+      'owaspTop10-2021': [],
+      cwe: [],
+      sonarsourceSecurity: [],
+    });
+  };
+
+  handleItemClick = (prop: ValuesProp, itemValue: string, multiple: boolean) => {
+    const items = this.props[prop];
+
+    if (multiple) {
+      const newValue = sortBy(
+        items.includes(itemValue) ? without(items, itemValue) : [...items, itemValue]
+      );
+
+      this.props.onChange({ [prop]: newValue });
+    } else {
+      this.props.onChange({
+        [prop]: items.includes(itemValue) && items.length < 2 ? [] : [itemValue],
+      });
+    }
+  };
+
+  handleOwaspTop10ItemClick = (itemValue: string, multiple: boolean) => {
+    this.handleItemClick(SecurityStandard.OWASP_TOP10, itemValue, multiple);
+  };
+
+  handleOwaspTop102021ItemClick = (itemValue: string, multiple: boolean) => {
+    this.handleItemClick(SecurityStandard.OWASP_TOP10_2021, itemValue, multiple);
+  };
+
+  handleSonarSourceSecurityItemClick = (itemValue: string, multiple: boolean) => {
+    this.handleItemClick(SecurityStandard.SONARSOURCE, itemValue, multiple);
+  };
+
+  handleCWESearch = (query: string) => {
+    return Promise.resolve({
+      results: Object.keys(this.state.standards.cwe).filter((cwe) =>
+        renderCWECategory(this.state.standards, cwe).toLowerCase().includes(query.toLowerCase())
+      ),
+    });
+  };
+
+  loadCWESearchResultCount = (categories: string[]) => {
+    const { loadSearchResultCount } = this.props;
+
+    return loadSearchResultCount
+      ? loadSearchResultCount('cwe', { cwe: categories })
+      : Promise.resolve({});
+  };
+
+  renderList = (
+    statsProp: StatsProp,
+    valuesProp: ValuesProp,
+    renderName: (standards: Standards, category: string) => string,
+    onClick: (x: string, multiple?: boolean) => void
+  ) => {
+    const stats = this.props[statsProp];
+    const values = this.props[valuesProp];
+
+    if (!stats) {
+      return null;
+    }
+
+    const categories = sortBy(Object.keys(stats), (key) => -stats[key]);
+
+    return this.renderFacetItemsList(
+      stats,
+      values,
+      categories,
+      valuesProp,
+      renderName,
+      renderName,
+      onClick
+    );
+  };
+
+  // eslint-disable-next-line max-params
+  renderFacetItemsList = (
+    stats: Dict<number | undefined>,
+    values: string[],
+    categories: string[],
+    listKey: ValuesProp,
+    renderName: (standards: Standards, category: string) => React.ReactNode,
+    renderTooltip: (standards: Standards, category: string) => string,
+    onClick: (x: string, multiple?: boolean) => void
+  ) => {
+    if (!categories.length) {
+      return (
+        <div className="search-navigator-facet-empty little-spacer-top">
+          {translate('no_results')}
+        </div>
+      );
+    }
+
+    const getStat = (category: string) => {
+      return stats ? stats[category] : undefined;
+    };
+
+    return (
+      <FacetItemsList labelledby={this.getFacetHeaderId(listKey)}>
+        {categories.map((category) => (
+          <FacetItem
+            active={values.includes(category)}
+            key={category}
+            name={renderName(this.state.standards, category)}
+            onClick={onClick}
+            stat={formatFacetStat(getStat(category))}
+            tooltip={renderTooltip(this.state.standards, category)}
+            value={category}
+          />
+        ))}
+      </FacetItemsList>
+    );
+  };
+
+  renderHint = (statsProp: StatsProp, valuesProp: ValuesProp) => {
+    const stats = this.props[statsProp] ?? {};
+    const values = this.props[valuesProp];
+
+    return <MultipleSelectionHint options={Object.keys(stats).length} values={values.length} />;
+  };
+
+  renderOwaspTop10List() {
+    return this.renderList(
+      'owaspTop10Stats',
+      SecurityStandard.OWASP_TOP10,
+      renderOwaspTop10Category,
+      this.handleOwaspTop10ItemClick
+    );
+  }
+
+  renderOwaspTop102021List() {
+    return this.renderList(
+      'owaspTop10-2021Stats',
+      SecurityStandard.OWASP_TOP10_2021,
+      renderOwaspTop102021Category,
+      this.handleOwaspTop102021ItemClick
+    );
+  }
+
+  renderSonarSourceSecurityList() {
+    const stats = this.props.sonarsourceSecurityStats;
+    const values = this.props.sonarsourceSecurity;
+
+    if (!stats) {
+      return null;
+    }
+
+    const sortedItems = sortBy(
+      Object.keys(stats),
+      (key) => -stats[key],
+      (key) => renderSonarSourceSecurityCategory(this.state.standards, key)
+    );
+
+    const limitedList = this.state.showFullSonarSourceList
+      ? sortedItems
+      : sortedItems.slice(0, INITIAL_FACET_COUNT);
+
+    // make sure all selected items are displayed
+    const selectedBelowLimit = this.state.showFullSonarSourceList
+      ? []
+      : sortedItems.slice(INITIAL_FACET_COUNT).filter((item) => values.includes(item));
+
+    const allItemShown = limitedList.length + selectedBelowLimit.length === sortedItems.length;
+
+    return (
+      <>
+        <FacetItemsList labelledby={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}>
+          {limitedList.map((item) => (
+            <FacetItem
+              active={values.includes(item)}
+              key={item}
+              name={renderSonarSourceSecurityCategory(this.state.standards, item)}
+              onClick={this.handleSonarSourceSecurityItemClick}
+              stat={formatFacetStat(stats[item])}
+              tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
+              value={item}
+            />
+          ))}
+        </FacetItemsList>
+
+        {selectedBelowLimit.length > 0 && (
+          <>
+            {!allItemShown && <div className="note spacer-bottom text-center">⋯</div>}
+            <FacetItemsList labelledby={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}>
+              {selectedBelowLimit.map((item) => (
+                <FacetItem
+                  active={true}
+                  key={item}
+                  name={renderSonarSourceSecurityCategory(this.state.standards, item)}
+                  onClick={this.handleSonarSourceSecurityItemClick}
+                  stat={formatFacetStat(stats[item])}
+                  tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
+                  value={item}
+                />
+              ))}
+            </FacetItemsList>
+          </>
+        )}
+
+        {!allItemShown && (
+          <ListStyleFacetFooter
+            showMoreAriaLabel={translate('issues.facet.sonarsource.show_more')}
+            count={limitedList.length + selectedBelowLimit.length}
+            showMore={() => this.setState({ showFullSonarSourceList: true })}
+            total={sortedItems.length}
+          />
+        )}
+      </>
+    );
+  }
+
+  renderOwaspTop10Hint() {
+    return this.renderHint('owaspTop10Stats', SecurityStandard.OWASP_TOP10);
+  }
+
+  renderOwaspTop102021Hint() {
+    return this.renderHint('owaspTop10-2021Stats', SecurityStandard.OWASP_TOP10_2021);
+  }
+
+  renderSonarSourceSecurityHint() {
+    return this.renderHint('sonarsourceSecurityStats', SecurityStandard.SONARSOURCE);
+  }
+
+  renderSubFacets() {
+    const {
+      cwe,
+      cweOpen,
+      cweStats,
+      fetchingCwe,
+      fetchingOwaspTop10,
+      'fetchingOwaspTop10-2021': fetchingOwaspTop102021,
+      fetchingSonarSourceSecurity,
+      owaspTop10,
+      owaspTop10Open,
+      'owaspTop10-2021Open': owaspTop102021Open,
+      'owaspTop10-2021': owaspTop102021,
+      query,
+      sonarsourceSecurity,
+      sonarsourceSecurityOpen,
+    } = this.props;
+
+    return (
+      <>
+        <FacetBox className="is-inner" property={SecurityStandard.SONARSOURCE}>
+          <FacetHeader
+            fetching={fetchingSonarSourceSecurity}
+            id={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}
+            name={translate('issues.facet.sonarsourceSecurity')}
+            onClick={this.handleSonarSourceSecurityHeaderClick}
+            open={sonarsourceSecurityOpen}
+            values={sonarsourceSecurity.map((item) =>
+              renderSonarSourceSecurityCategory(this.state.standards, item)
+            )}
+          />
+
+          {sonarsourceSecurityOpen && (
+            <>
+              {this.renderSonarSourceSecurityList()}
+              {this.renderSonarSourceSecurityHint()}
+            </>
+          )}
+        </FacetBox>
+
+        <FacetBox className="is-inner" property={SecurityStandard.OWASP_TOP10_2021}>
+          <FacetHeader
+            fetching={fetchingOwaspTop102021}
+            id={this.getFacetHeaderId(SecurityStandard.OWASP_TOP10_2021)}
+            name={translate('issues.facet.owaspTop10_2021')}
+            onClick={this.handleOwaspTop102021HeaderClick}
+            open={owaspTop102021Open}
+            values={owaspTop102021.map((item) =>
+              renderOwaspTop102021Category(this.state.standards, item)
+            )}
+          />
+
+          {owaspTop102021Open && (
+            <>
+              {this.renderOwaspTop102021List()}
+              {this.renderOwaspTop102021Hint()}
+            </>
+          )}
+        </FacetBox>
+
+        <FacetBox className="is-inner" property={SecurityStandard.OWASP_TOP10}>
+          <FacetHeader
+            fetching={fetchingOwaspTop10}
+            id={this.getFacetHeaderId(SecurityStandard.OWASP_TOP10)}
+            name={translate('issues.facet.owaspTop10')}
+            onClick={this.handleOwaspTop10HeaderClick}
+            open={owaspTop10Open}
+            values={owaspTop10.map((item) => renderOwaspTop10Category(this.state.standards, item))}
+          />
+
+          {owaspTop10Open && (
+            <>
+              {this.renderOwaspTop10List()}
+              {this.renderOwaspTop10Hint()}
+            </>
+          )}
+        </FacetBox>
+
+        <ListStyleFacet<string>
+          className="is-inner"
+          facetHeader={translate('issues.facet.cwe')}
+          fetching={fetchingCwe}
+          getFacetItemText={(item) => renderCWECategory(this.state.standards, item)}
+          getSearchResultKey={(item) => item}
+          getSearchResultText={(item) => renderCWECategory(this.state.standards, item)}
+          loadSearchResultCount={this.loadCWESearchResultCount}
+          onChange={this.props.onChange}
+          onSearch={this.handleCWESearch}
+          onToggle={this.props.onToggle}
+          open={cweOpen}
+          property={SecurityStandard.CWE}
+          query={omit(query, 'cwe')}
+          renderFacetItem={(item) => renderCWECategory(this.state.standards, item)}
+          renderSearchResult={(item, query) =>
+            highlightTerm(renderCWECategory(this.state.standards, item), query)
+          }
+          searchPlaceholder={translate('search.search_for_cwe')}
+          stats={cweStats}
+          values={cwe}
+        />
+      </>
+    );
+  }
+
+  render() {
+    const { open } = this.props;
+
+    return (
+      <FacetBox property={this.property}>
+        <FacetHeader
+          id={this.getFacetHeaderId(this.property)}
+          name={translate('issues.facet', this.property)}
+          onClear={this.handleClear}
+          onClick={this.handleHeaderClick}
+          open={open}
+          values={this.getValues()}
+        />
+
+        {open && this.renderSubFacets()}
+      </FacetBox>
+    );
+  }
+}
index 34de8e4547d53dc9e3da0521b4bf0d3ce437c391..a0282be1eeab97630a4118a4a3c0c958d4ff1430 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, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import selectEvent from 'react-select-event';
@@ -361,6 +362,7 @@ describe('issues app', () => {
 
       // Status
       await user.click(ui.statusFacet.get());
+
       await user.click(ui.openStatusFilter.get());
       expect(ui.issueItem6.query()).not.toBeInTheDocument(); // Issue 6 should vanish
 
@@ -376,10 +378,13 @@ describe('issues app', () => {
       // Rule
       await user.click(ui.ruleFacet.get());
       await user.click(screen.getByRole('checkbox', { name: 'other' }));
-      expect(screen.getByRole('checkbox', { name: '(HTML) Advanced rule' })).toBeInTheDocument(); // Name should apply to the rule
+
+      // Name should apply to the rule
+      expect(screen.getByRole('checkbox', { name: '(HTML) Advanced rule' })).toBeInTheDocument();
 
       // Tag
       await user.click(ui.tagFacet.get());
+      await user.type(ui.tagFacetSearch.get(), 'unu');
       await user.click(screen.getByRole('checkbox', { name: 'unused' }));
 
       // Project
@@ -393,6 +398,7 @@ describe('issues app', () => {
 
       // Author
       await user.click(ui.authorFacet.get());
+      await user.type(ui.authorFacetSearch.get(), 'email');
       await user.click(screen.getByRole('checkbox', { name: 'email4@sonarsource.com' }));
       await user.click(screen.getByRole('checkbox', { name: 'email3@sonarsource.com' })); // Change author
       expect(ui.issueItem1.query()).not.toBeInTheDocument();
@@ -455,15 +461,28 @@ describe('issues app', () => {
       const user = userEvent.setup();
       const currentUser = mockLoggedInUser();
       issuesHandler.setCurrentUser(currentUser);
+
       renderIssueApp(currentUser);
+
       await waitOnDataLoaded();
 
       // Select a specific date range such that only one issue matches
       await user.click(ui.creationDateFacet.get());
       await user.click(screen.getByPlaceholderText('start_date'));
-      await user.selectOptions(ui.dateInputMonthSelect.get(), 'January');
-      await user.selectOptions(ui.dateInputYearSelect.get(), '2023');
-      await user.click(screen.getByText('1'));
+
+      const monthSelector = within(ui.dateInputMonthSelect.get()).getByRole('combobox');
+
+      await user.click(monthSelector);
+
+      await user.click(within(ui.dateInputMonthSelect.get()).getByText('Jan'));
+
+      const yearSelector = within(ui.dateInputYearSelect.get()).getByRole('combobox');
+
+      await user.click(yearSelector);
+
+      await user.click(within(ui.dateInputYearSelect.get()).getAllByText('2023')[-1]);
+
+      await user.click(screen.getByText('1', { selector: 'button' }));
       await user.click(screen.getByText('10'));
 
       expect(ui.issueItem1.get()).toBeInTheDocument();
@@ -487,12 +506,12 @@ describe('issues app', () => {
       expect(ui.issueItem3.get()).toBeInTheDocument();
 
       // Only show my issues
-      await user.click(screen.getByRole('button', { name: 'issues.my_issues' }));
+      await user.click(screen.getByRole('radio', { name: 'issues.my_issues' }));
       expect(ui.issueItem2.query()).not.toBeInTheDocument();
       expect(ui.issueItem3.get()).toBeInTheDocument();
 
       // Show all issues again
-      await user.click(screen.getByRole('button', { name: 'all' }));
+      await user.click(screen.getByRole('radio', { name: 'all' }));
       expect(ui.issueItem2.get()).toBeInTheDocument();
       expect(ui.issueItem3.get()).toBeInTheDocument();
     });
@@ -503,13 +522,17 @@ describe('issues app', () => {
       renderIssueApp();
 
       await user.click(await ui.ruleFacet.find());
+
       await user.type(ui.ruleFacetSearch.get(), 'rule');
+
       expect(within(ui.ruleFacetList.get()).getAllByRole('checkbox')).toHaveLength(2);
+
       expect(
         within(ui.ruleFacetList.get()).getByRole('checkbox', {
           name: /Advanced rule/,
         })
       ).toBeInTheDocument();
+
       expect(
         within(ui.ruleFacetList.get()).getByRole('checkbox', {
           name: /Simple rule/,
index f62a4bb2c9e4eac649077f71aaf5136d68cb066a..5da901f7a48e5a6633ec12845bc8396474fa4d5d 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 { debounce } from 'lodash';
 import * as React from 'react';
 import { components, OptionProps, SingleValueProps } from 'react-select';
@@ -85,7 +86,7 @@ export default class AssigneeSelect extends React.Component<AssigneeSelectProps>
     searchAssignees(query)
       .then(({ results }) =>
         results.map((r) => {
-          const userInfo = r.name || r.login;
+          const userInfo = r.name ?? r.login;
 
           return {
             avatar: r.avatar,
index 8ace857ba5d44a25cddc7ad617dd542b35d8093d..2326968dc26eb8b9ed3b9b88efd7ccdeb8f5287d 100644 (file)
@@ -17,8 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import styled from '@emotion/styled';
+
 import classNames from 'classnames';
+import { FlagMessage, ToggleButton } from 'design-system';
 import { debounce, keyBy, omit, without } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
@@ -31,11 +32,8 @@ import withCurrentUserContext from '../../../app/components/current-user/withCur
 import { PageContext } from '../../../app/components/indexation/PageUnavailableDueToIndexation';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
 import EmptySearch from '../../../components/common/EmptySearch';
-import FiltersHeader from '../../../components/common/FiltersHeader';
 import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper';
-import ButtonToggle from '../../../components/controls/ButtonToggle';
 import Checkbox from '../../../components/controls/Checkbox';
-import HelpTooltip from '../../../components/controls/HelpTooltip';
 import ListFooter from '../../../components/controls/ListFooter';
 import { Button } from '../../../components/controls/buttons';
 import Suggestions from '../../../components/embed-docs-modal/Suggestions';
@@ -43,7 +41,6 @@ import withIndexationGuard from '../../../components/hoc/withIndexationGuard';
 import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
 import RuleTabViewer from '../../../components/rules/RuleTabViewer';
 import '../../../components/search-navigator.css';
-import { Alert } from '../../../components/ui/Alert';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import {
   fillBranchLike,
@@ -78,7 +75,8 @@ import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../
 import { CurrentUser, UserBase } from '../../../types/users';
 import * as actions from '../actions';
 import SubnavigationIssuesList from '../issues-subnavigation/SubnavigationIssuesList';
-import Sidebar from '../sidebar/Sidebar';
+import { FiltersHeader } from '../sidebar/FiltersHeader';
+import { Sidebar } from '../sidebar/Sidebar';
 import '../styles.css';
 import {
   Query,
@@ -156,6 +154,7 @@ export class App extends React.PureComponent<Props, State> {
     super(props);
     const query = parseQuery(props.location.query);
     this.bulkButtonRef = React.createRef();
+
     this.state = {
       bulkChangeModal: false,
       checked: [],
@@ -188,6 +187,7 @@ export class App extends React.PureComponent<Props, State> {
       referencedUsers: {},
       selected: getOpen(props.location.query),
     };
+
     this.refreshBranchStatus = debounce(this.refreshBranchStatus, BRANCH_STATUS_REFRESH_INTERVAL);
   }
 
@@ -214,7 +214,7 @@ export class App extends React.PureComponent<Props, State> {
     addWhitePageClass();
     addSideBarClass();
     this.attachShortcuts();
-    this.fetchFirstIssues(true);
+    this.fetchFirstIssues(true).catch(() => undefined);
   }
 
   componentDidUpdate(prevProps: Props, prevState: State) {
@@ -228,7 +228,7 @@ export class App extends React.PureComponent<Props, State> {
       !areQueriesEqual(prevQuery, query) ||
       areMyIssuesSelected(prevQuery) !== areMyIssuesSelected(query)
     ) {
-      this.fetchFirstIssues(false);
+      this.fetchFirstIssues(false).catch(() => undefined);
       this.setState({ checkAll: false });
     } else if (openIssue && openIssue.key !== this.state.selected) {
       this.setState({
@@ -238,14 +238,16 @@ export class App extends React.PureComponent<Props, State> {
         selectedLocationIndex: undefined,
       });
     }
+
     if (this.state.openIssue && this.state.openIssue.key !== prevState.openIssue?.key) {
-      this.loadRule();
+      this.loadRule().catch(() => undefined);
     }
   }
 
   componentWillUnmount() {
     this.detachShortcuts();
     this.mounted = false;
+
     removeWhitePageClass();
     removeSideBarClass();
   }
@@ -284,38 +286,46 @@ export class App extends React.PureComponent<Props, State> {
     switch (event.key) {
       case KeyboardKeys.DownArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectNextLocation();
         } else {
           this.selectNextIssue();
         }
+
         break;
       }
       case KeyboardKeys.UpArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectPreviousLocation();
         } else {
           this.selectPreviousIssue();
         }
+
         break;
       }
       case KeyboardKeys.LeftArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectPreviousFlow();
         } else {
           this.closeIssue();
         }
+
         break;
       }
       case KeyboardKeys.RightArrow: {
         event.preventDefault();
+
         if (event.altKey) {
           this.selectNextFlow();
         } else {
           this.openSelectedIssue();
         }
+
         break;
       }
     }
@@ -330,12 +340,14 @@ export class App extends React.PureComponent<Props, State> {
   getSelectedIndex() {
     const { issues = [], selected } = this.state;
     const index = issues.findIndex((issue) => issue.key === selected);
+
     return index !== -1 ? index : undefined;
   }
 
   selectNextIssue = () => {
     const { issues } = this.state;
     const selectedIndex = this.getSelectedIndex();
+
     if (selectedIndex !== undefined && selectedIndex < issues.length - 1) {
       if (this.state.openIssue) {
         this.openIssue(issues[selectedIndex + 1].key);
@@ -351,13 +363,17 @@ export class App extends React.PureComponent<Props, State> {
 
   async loadRule() {
     const { openIssue } = this.state;
+
     if (openIssue === undefined) {
       return;
     }
+
     this.setState({ loadingRule: true });
+
     const openRuleDetails = await getRuleDetails({ key: openIssue.rule })
       .then((response) => response.rule)
       .catch(() => undefined);
+
     if (this.mounted) {
       this.setState({ loadingRule: false, openRuleDetails });
     }
@@ -366,6 +382,7 @@ export class App extends React.PureComponent<Props, State> {
   selectPreviousIssue = () => {
     const { issues } = this.state;
     const selectedIndex = this.getSelectedIndex();
+
     if (selectedIndex !== undefined && selectedIndex > 0) {
       if (this.state.openIssue) {
         this.openIssue(issues[selectedIndex - 1].key);
@@ -385,11 +402,12 @@ export class App extends React.PureComponent<Props, State> {
       query: {
         ...serializeQuery(this.state.query),
         ...getBranchLikeQuery(this.props.branchLike),
-        id: this.props.component && this.props.component.key,
+        id: this.props.component?.key,
         myIssues: this.state.myIssues ? 'true' : undefined,
         open: issueKey,
       },
     };
+
     if (this.state.openIssue) {
       if (path.query.open && path.query.open === this.state.openIssue.key) {
         this.setState({
@@ -411,7 +429,7 @@ export class App extends React.PureComponent<Props, State> {
         query: {
           ...serializeQuery(this.state.query),
           ...getBranchLikeQuery(this.props.branchLike),
-          id: this.props.component && this.props.component.key,
+          id: this.props.component?.key,
           myIssues: this.state.myIssues ? 'true' : undefined,
           open: undefined,
         },
@@ -421,6 +439,7 @@ export class App extends React.PureComponent<Props, State> {
 
   openSelectedIssue = () => {
     const { selected } = this.state;
+
     if (selected) {
       this.openIssue(selected);
     }
@@ -437,6 +456,7 @@ export class App extends React.PureComponent<Props, State> {
       const parsedIssues = response.issues.map((issue) =>
         parseIssueFromResponse(issue, response.components, response.users, response.rules)
       );
+
       return { ...response, issues: parsedIssues } as FetchIssuesPromise;
     });
   };
@@ -461,7 +481,7 @@ export class App extends React.PureComponent<Props, State> {
 
     const parameters: Dict<string | undefined> = {
       ...getBranchLikeQuery(this.props.branchLike),
-      componentKeys: component && component.key,
+      componentKeys: component?.key,
       s: 'FILE_LINE',
       ...serializeQuery(query),
       ps: '100',
@@ -491,6 +511,7 @@ export class App extends React.PureComponent<Props, State> {
     let fetchPromise;
 
     this.setState({ checked: [], loading: true });
+
     if (openIssueKey !== undefined) {
       fetchPromise = this.fetchIssuesUntil(1, (pageIssues: Issue[], paging: Paging) => {
         if (
@@ -499,6 +520,7 @@ export class App extends React.PureComponent<Props, State> {
         ) {
           return true;
         }
+
         return pageIssues.some((issue) => issue.key === openIssueKey);
       });
     } else {
@@ -510,9 +532,11 @@ export class App extends React.PureComponent<Props, State> {
         if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) {
           const openIssue = getOpenIssue(this.props, issues);
           let selected: string | undefined = undefined;
+
           if (issues.length > 0) {
             selected = openIssue ? openIssue.key : issues[0].key;
           }
+
           this.setState(({ showVariantsFilter }) => ({
             cannotShowOpenIssue: Boolean(openIssueKey && !openIssue),
             effortTotal,
@@ -535,12 +559,14 @@ export class App extends React.PureComponent<Props, State> {
             selectedLocationIndex: undefined,
           }));
         }
+
         return issues;
       },
       () => {
         if (this.mounted && areQueriesEqual(prevQuery, this.props.location.query)) {
           this.setState({ loading: false });
         }
+
         return [];
       }
     );
@@ -557,6 +583,8 @@ export class App extends React.PureComponent<Props, State> {
     const recursiveFetch = (p: number, prevIssues: Issue[]): Promise<FetchIssuesPromise> => {
       return this.fetchIssuesPage(p).then(({ issues: pageIssues, paging, ...other }) => {
         const issues = [...prevIssues, ...pageIssues];
+
+        // eslint-disable-next-line promise/no-callback-in-promise
         return done(pageIssues, paging)
           ? { issues, paging, ...other }
           : recursiveFetch(p + 1, issues);
@@ -576,6 +604,7 @@ export class App extends React.PureComponent<Props, State> {
     const p = paging.pageIndex + 1;
 
     this.setState({ checkAll: false, loadingMore: true });
+
     return this.fetchIssuesPage(p).then(
       (response) => {
         if (this.mounted) {
@@ -626,6 +655,7 @@ export class App extends React.PureComponent<Props, State> {
 
   isFiltered = () => {
     const serialized = serializeQuery(this.state.query);
+
     return !areQueriesEqual(serialized, DEFAULT_QUERY);
   };
 
@@ -633,7 +663,9 @@ export class App extends React.PureComponent<Props, State> {
     const issues = this.state.checked
       .map((checked) => this.state.issues.find((issue) => issue.key === checked))
       .filter((issue): issue is Issue => issue !== undefined);
+
     const paging = { pageIndex: 1, pageSize: issues.length, total: issues.length };
+
     return Promise.resolve({ issues, paging });
   };
 
@@ -643,6 +675,7 @@ export class App extends React.PureComponent<Props, State> {
     }
 
     let count;
+
     if (checkAll && paging) {
       count = paging.total > MAX_PAGE_SIZE ? MAX_PAGE_SIZE : paging.total;
     } else {
@@ -658,10 +691,11 @@ export class App extends React.PureComponent<Props, State> {
       query: {
         ...serializeQuery({ ...this.state.query, ...changes }),
         ...getBranchLikeQuery(this.props.branchLike),
-        id: this.props.component && this.props.component.key,
+        id: this.props.component?.key,
         myIssues: this.state.myIssues ? 'true' : undefined,
       },
     });
+
     this.setState(({ openFacets }) => ({
       openFacets: {
         ...openFacets,
@@ -673,15 +707,17 @@ export class App extends React.PureComponent<Props, State> {
 
   handleMyIssuesChange = (myIssues: boolean) => {
     this.closeFacet('assignees');
+
     if (!this.props.component) {
       saveMyIssues(myIssues);
     }
+
     this.props.router.push({
       pathname: this.props.location.pathname,
       query: {
         ...serializeQuery({ ...this.state.query, assigned: true, assignees: [] }),
         ...getBranchLikeQuery(this.props.branchLike),
-        id: this.props.component && this.props.component.key,
+        id: this.props.component?.key,
         myIssues: myIssues ? 'true' : undefined,
       },
     });
@@ -693,7 +729,7 @@ export class App extends React.PureComponent<Props, State> {
 
     const parameters = {
       ...getBranchLikeQuery(this.props.branchLike),
-      componentKeys: component && component.key,
+      componentKeys: component?.key,
       facets: property,
       s: 'FILE_LINE',
       ...serializeQuery({ ...query, ...changes }),
@@ -716,6 +752,7 @@ export class App extends React.PureComponent<Props, State> {
   handleFacetToggle = (property: string) => {
     this.setState((state) => {
       const willOpenProperty = !state.openFacets[property];
+
       const newState = {
         loadingFacets: state.loadingFacets,
         openFacets: { ...state.openFacets, [property]: willOpenProperty },
@@ -727,6 +764,7 @@ export class App extends React.PureComponent<Props, State> {
           newState.openFacets,
           state.query
         );
+
         // Force loading of sonarsource security facet data
         property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property;
       }
@@ -734,7 +772,8 @@ export class App extends React.PureComponent<Props, State> {
       // No need to load facets data for standard facet
       if (property !== STANDARDS && !state.facets[property]) {
         newState.loadingFacets[property] = true;
-        this.fetchFacet(property);
+
+        this.fetchFacet(property).catch(() => undefined);
       }
 
       return newState;
@@ -747,7 +786,7 @@ export class App extends React.PureComponent<Props, State> {
       query: {
         ...DEFAULT_QUERY,
         ...getBranchLikeQuery(this.props.branchLike),
-        id: this.props.component && this.props.component.key,
+        id: this.props.component?.key,
         myIssues: this.state.myIssues ? 'true' : undefined,
       },
     });
@@ -779,6 +818,7 @@ export class App extends React.PureComponent<Props, State> {
 
   handleIssueChange = (issue: Issue) => {
     this.refreshBranchStatus();
+
     this.setState((state) => ({
       issues: state.issues.map((candidate) => (candidate.key === issue.key ? issue : candidate)),
     }));
@@ -799,12 +839,13 @@ export class App extends React.PureComponent<Props, State> {
   handleBulkChangeDone = () => {
     this.setState({ checkAll: false });
     this.refreshBranchStatus();
-    this.fetchFirstIssues(false);
+    this.fetchFirstIssues(false).catch(() => undefined);
     this.handleCloseBulkChange();
   };
 
   selectLocation = (index: number) => {
     const { selectedLocationIndex } = this.state;
+
     if (index === selectedLocationIndex) {
       this.setState({ selectedLocationIndex: undefined }, () => {
         this.setState({ selectedLocationIndex: index });
@@ -814,6 +855,7 @@ export class App extends React.PureComponent<Props, State> {
         if (openIssue) {
           return { locationsNavigator: true, selectedLocationIndex: index };
         }
+
         return null;
       });
     }
@@ -852,6 +894,7 @@ export class App extends React.PureComponent<Props, State> {
 
   refreshBranchStatus = () => {
     const { branchLike, component } = this.props;
+
     if (branchLike && component && isPullRequest(branchLike)) {
       this.props.fetchBranchStatus(branchLike, component.key);
     }
@@ -880,6 +923,7 @@ export class App extends React.PureComponent<Props, State> {
           thirdState={thirdState}
           title={translate('issues.select_all_issues')}
         />
+
         <Button
           innerRef={this.bulkButtonRef}
           disabled={checked.length === 0}
@@ -902,7 +946,7 @@ export class App extends React.PureComponent<Props, State> {
     );
   }
 
-  renderFacets() {
+  renderFacets(warning?: React.ReactNode) {
     const { component, currentUser, branchLike } = this.props;
     const {
       query,
@@ -919,20 +963,30 @@ export class App extends React.PureComponent<Props, State> {
     } = this.state;
 
     return (
-      <div className="layout-page-filters">
+      <div
+        className={
+          'it__layout-page-filters sw-bg-white sw-box-border sw-h-full sw-overflow-y-auto ' +
+          'sw-pt-6 sw-pl-3 sw-pr-4 sw-w-[300px] lg:sw-w-[390px]'
+        }
+        style={{ borderLeft: '1px solid #dddddd', borderTop: '1px solid #dddddd' }}
+      >
+        {warning}
+
         {currentUser.isLoggedIn && (
-          <div className="display-flex-justify-center big-spacer-bottom">
-            <ButtonToggle
+          <div className="sw-flex sw-justify-start sw-mb-8">
+            <ToggleButton
+              onChange={this.handleMyIssuesChange}
               options={[
                 { value: true, label: translate('issues.my_issues') },
                 { value: false, label: translate('all') },
               ]}
               value={this.state.myIssues}
-              onCheck={this.handleMyIssuesChange}
             />
           </div>
         )}
+
         <FiltersHeader displayReset={this.isFiltered()} onReset={this.handleReset} />
+
         <Sidebar
           branchLike={branchLike}
           component={component}
@@ -958,9 +1012,28 @@ export class App extends React.PureComponent<Props, State> {
 
   renderSide(openIssue: Issue | undefined) {
     const { canBrowseAllChildProjects, qualifier = ComponentQualifier.Project } =
-      this.props.component || {};
+      this.props.component ?? {};
 
-    const { issues, paging } = this.state;
+    const {
+      issues,
+      loading,
+      loadingMore,
+      paging,
+      selected,
+      selectedFlowIndex,
+      selectedLocationIndex,
+    } = this.state;
+
+    const warning = !canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
+      <FlagMessage
+        ariaLabel={translate('issues.not_all_issue_show')}
+        className="it__portfolio_warning sw-flex sw-my-4"
+        title={translate('issues.not_all_issue_show_why')}
+        variant="warning"
+      >
+        {translate('issues.not_all_issue_show')}
+      </FlagMessage>
+    );
 
     return (
       <ScreenPositionHelper className="layout-page-side-outer">
@@ -970,7 +1043,7 @@ export class App extends React.PureComponent<Props, State> {
             className="layout-page-side"
             style={{ top }}
           >
-            <div className="layout-page-side-inner">
+            <div className="sw-flex sw-h-full sw-justify-end">
               <A11ySkipTarget
                 anchor="issues_sidebar"
                 label={
@@ -978,40 +1051,29 @@ export class App extends React.PureComponent<Props, State> {
                 }
                 weight={10}
               />
-              {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
-                <div
-                  className={classNames('not-all-issue-warning', {
-                    'open-issue-list': openIssue,
-                  })}
-                >
-                  <Alert className={classNames('it__portfolio_warning')} variant="warning">
-                    <AlertContent>
-                      {translate('issues.not_all_issue_show')}
-                      <HelpTooltip
-                        className="spacer-left"
-                        overlay={translate('issues.not_all_issue_show_why')}
-                      />
-                    </AlertContent>
-                  </Alert>
-                </div>
-              )}
 
               {openIssue ? (
-                <SubnavigationIssuesList
-                  fetchMoreIssues={this.fetchMoreIssues}
-                  issues={issues}
-                  loading={this.state.loading}
-                  loadingMore={this.state.loadingMore}
-                  onFlowSelect={this.selectFlow}
-                  onIssueSelect={this.openIssue}
-                  onLocationSelect={this.selectLocation}
-                  paging={paging}
-                  selected={this.state.selected}
-                  selectedFlowIndex={this.state.selectedFlowIndex}
-                  selectedLocationIndex={this.state.selectedLocationIndex}
-                />
+                <div>
+                  <div className={classNames('not-all-issue-warning', 'open-issue-list')}>
+                    {warning}
+                  </div>
+
+                  <SubnavigationIssuesList
+                    fetchMoreIssues={this.fetchMoreIssues}
+                    issues={issues}
+                    loading={loading}
+                    loadingMore={loadingMore}
+                    onFlowSelect={this.selectFlow}
+                    onIssueSelect={this.openIssue}
+                    onLocationSelect={this.selectLocation}
+                    paging={paging}
+                    selected={selected}
+                    selectedFlowIndex={selectedFlowIndex}
+                    selectedLocationIndex={selectedLocationIndex}
+                  />
+                </div>
               ) : (
-                this.renderFacets()
+                this.renderFacets(warning)
               )}
             </div>
           </nav>
@@ -1031,6 +1093,7 @@ export class App extends React.PureComponent<Props, State> {
     }
 
     let noIssuesMessage = null;
+
     if (paging.total === 0 && !loading) {
       if (this.isFiltered()) {
         noIssuesMessage = <EmptySearch />;
@@ -1044,6 +1107,7 @@ export class App extends React.PureComponent<Props, State> {
     return (
       <div>
         <h2 className="a11y-hidden">{translate('list_of_issues')}</h2>
+
         {paging.total > 0 && (
           <IssuesList
             branchLike={branchLike}
@@ -1063,7 +1127,9 @@ export class App extends React.PureComponent<Props, State> {
         {paging.total > 0 && (
           <ListFooter
             count={issues.length}
-            loadMore={this.fetchMoreIssues}
+            loadMore={() => {
+              this.fetchMoreIssues().catch(() => undefined);
+            }}
             loading={loadingMore}
             total={paging.total}
           />
@@ -1092,6 +1158,7 @@ export class App extends React.PureComponent<Props, State> {
             <A11ySkipTarget anchor="issues_main" />
 
             {this.renderBulkChange()}
+
             <PageActions
               canSetHome={!this.props.component}
               effortTotal={this.state.effortTotal}
@@ -1118,6 +1185,7 @@ export class App extends React.PureComponent<Props, State> {
     return (
       <div className="layout-page-main-inner">
         <DeferredSpinner loading={loadingRule}>
+          {/* eslint-disable-next-line local-rules/no-conditional-rendering-of-deferredspinner */}
           {openIssue && openRuleDetails ? (
             <>
               <IssueHeader
@@ -1126,6 +1194,7 @@ export class App extends React.PureComponent<Props, State> {
                 branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
                 onIssueChange={this.handleIssueChange}
               />
+
               <RuleTabViewer
                 ruleDetails={openRuleDetails}
                 extendedDescription={openRuleDetails.htmlNote}
@@ -1148,22 +1217,35 @@ export class App extends React.PureComponent<Props, State> {
           ) : (
             <DeferredSpinner loading={loading} ariaLabel={translate('issues.loading_issues')}>
               {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
-                <Alert className="big-spacer-bottom" variant="warning">
+                <FlagMessage
+                  ariaLabel={translate('issue_bulk_change.max_issues_reached')}
+                  className="sw-mb-4"
+                  variant="warning"
+                >
                   <FormattedMessage
                     defaultMessage={translate('issue_bulk_change.max_issues_reached')}
                     id="issue_bulk_change.max_issues_reached"
                     values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
                   />
-                </Alert>
+                </FlagMessage>
               )}
+
               {cannotShowOpenIssue && (!paging || paging.total > 0) && (
-                <Alert className="big-spacer-bottom" variant="warning">
+                <FlagMessage
+                  ariaLabel={translateWithParameters(
+                    'issues.cannot_open_issue_max_initial_X_fetched',
+                    MAX_INITAL_FETCH
+                  )}
+                  className="sw-mb-4"
+                  variant="warning"
+                >
                   {translateWithParameters(
                     'issues.cannot_open_issue_max_initial_X_fetched',
                     MAX_INITAL_FETCH
                   )}
-                </Alert>
+                </FlagMessage>
               )}
+
               {this.renderList()}
             </DeferredSpinner>
           )}
@@ -1176,12 +1258,14 @@ export class App extends React.PureComponent<Props, State> {
     const { component } = this.props;
     const { openIssue, paging } = this.state;
     const selectedIndex = this.getSelectedIndex();
+
     return (
       <div
         className={classNames('layout-page issues', { 'project-level': component !== undefined })}
         id="issues-page"
       >
         <Suggestions suggestions="issues" />
+
         {openIssue ? (
           <Helmet
             defer={false}
@@ -1209,11 +1293,6 @@ export class App extends React.PureComponent<Props, State> {
   }
 }
 
-const AlertContent = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
 export default withIndexationGuard(
   withRouter(withCurrentUserContext(withBranchStatusActions(withComponentContext(App)))),
   PageContext.Issues
index 89d3d4f6f314a24df3952e8a1ce9e913ca4a6fbd..95d4efd241040dc1eb0035bb0d23accb0009f5e4 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { Avatar } from 'design-system';
 import { omit, sortBy, without } from 'lodash';
 import * as React from 'react';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
 import { Facet } from '../../../types/issues';
 import { Dict } from '../../../types/types';
-import { isUserActive, UserBase } from '../../../types/users';
+import { UserBase, isUserActive } from '../../../types/users';
 import { Query, searchAssignees } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   assigned: boolean;
@@ -41,13 +42,14 @@ interface Props {
   referencedUsers: Dict<UserBase>;
 }
 
-export default class AssigneeFacet extends React.PureComponent<Props> {
+export class AssigneeFacet extends React.PureComponent<Props> {
   handleSearch = (query: string, page?: number) => {
     return searchAssignees(query, page);
   };
 
   handleItemClick = (itemValue: string, multiple: boolean) => {
     const { assignees } = this.props;
+
     if (itemValue === '') {
       // unassigned
       this.props.onChange({ assigned: !this.props.assigned, assignees: [] });
@@ -55,6 +57,7 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
       const newValue = sortBy(
         assignees.includes(itemValue) ? without(assignees, itemValue) : [...assignees, itemValue]
       );
+
       this.props.onChange({ assigned: true, assignees: newValue });
     } else {
       this.props.onChange({
@@ -71,13 +74,15 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
   getAssigneeName = (assignee: string) => {
     if (assignee === '') {
       return translate('unassigned');
-    } else {
-      const user = this.props.referencedUsers[assignee];
-      if (!user) {
-        return assignee;
-      }
-      return isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login);
     }
+
+    const user = this.props.referencedUsers[assignee];
+
+    if (!user) {
+      return assignee;
+    }
+
+    return isUserActive(user) ? user.name : translateWithParameters('user.x_deleted', user.login);
   };
 
   loadSearchResultCount = (assignees: UserBase[]) => {
@@ -89,6 +94,7 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
 
   getSortedItems = () => {
     const { stats = {} } = this.props;
+
     return sortBy(
       Object.keys(stats),
       // put "not assigned" first
@@ -109,11 +115,12 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
       return assignee;
     }
 
-    const userName = user.name || user.login;
+    const userName = user.name ?? user.login;
 
     return (
       <>
-        <Avatar className="little-spacer-right" hash={user.avatar} name={userName} size={16} />
+        <Avatar className="sw-mr-1" hash={user.avatar} name={userName} size="xs" />
+
         {isUserActive(user) ? userName : translateWithParameters('user.x_deleted', userName)}
       </>
     );
@@ -123,14 +130,16 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
     const displayName = isUserActive(result)
       ? result.name
       : translateWithParameters('user.x_deleted', result.login);
+
     return (
       <>
         <Avatar
-          className="little-spacer-right"
+          className="sw-mr-1"
           hash={result.avatar}
-          name={result.name || result.login}
-          size={16}
+          name={result.name ?? result.login}
+          size="xs"
         />
+
         {highlightTerm(displayName, query)}
       </>
     );
@@ -138,6 +147,7 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
 
   render() {
     const values = [...this.props.assignees];
+
     if (!this.props.assigned) {
       values.push('');
     }
@@ -148,7 +158,7 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
         fetching={this.props.fetching}
         getFacetItemText={this.getAssigneeName}
         getSearchResultKey={(user) => user.login}
-        getSearchResultText={(user) => user.name || user.login}
+        getSearchResultText={(user) => user.name ?? user.login}
         // put "not assigned" item first
         getSortedItems={this.getSortedItems}
         loadSearchResultCount={this.loadSearchResultCount}
index 1647c689495b7290016d160eec58a5bd441a47c6..021a3fec3cf19ee43811c849ab3152acba2f61ac 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { omit } from 'lodash';
 import * as React from 'react';
 import { searchIssueAuthors } from '../../../api/issues';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
 import { translate } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
+import { ComponentQualifier } from '../../../types/component';
 import { Facet } from '../../../types/issues';
 import { Component, Dict } from '../../../types/types';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
+  author: string[];
   component: Component | undefined;
   fetching: boolean;
   loadSearchResultCount: (property: string, changes: Partial<Query>) => Promise<Facet>;
@@ -36,20 +39,24 @@ interface Props {
   open: boolean;
   query: Query;
   stats: Dict<number> | undefined;
-  author: string[];
 }
 
 const SEARCH_SIZE = 100;
 
-export default class AuthorFacet extends React.PureComponent<Props> {
-  identity = (author: string) => {
-    return author;
-  };
-
+export class AuthorFacet extends React.PureComponent<Props> {
   handleSearch = (query: string, _page: number) => {
     const { component } = this.props;
+
     const project =
-      component && ['TRK', 'VW', 'APP'].includes(component.qualifier) ? component.key : undefined;
+      component &&
+      [
+        ComponentQualifier.Application,
+        ComponentQualifier.Portfolio,
+        ComponentQualifier.Project,
+      ].includes(component.qualifier as ComponentQualifier)
+        ? component.key
+        : undefined;
+
     return searchIssueAuthors({
       project,
       ps: SEARCH_SIZE, // maximum
@@ -70,9 +77,6 @@ export default class AuthorFacet extends React.PureComponent<Props> {
       <ListStyleFacet<string>
         facetHeader={translate('issues.facet.authors')}
         fetching={this.props.fetching}
-        getFacetItemText={this.identity}
-        getSearchResultKey={this.identity}
-        getSearchResultText={this.identity}
         loadSearchResultCount={this.loadSearchResultCount}
         onChange={this.props.onChange}
         onSearch={this.handleSearch}
@@ -80,7 +84,6 @@ export default class AuthorFacet extends React.PureComponent<Props> {
         open={this.props.open}
         property="author"
         query={omit(this.props.query, 'author')}
-        renderFacetItem={this.identity}
         renderSearchResult={this.renderSearchResult}
         searchPlaceholder={translate('search.search_for_authors')}
         stats={this.props.stats}
index b57a3228975a22d156e5b8154951f8c142b2c92a..39730c1831eea018fb634f6c31c96835164c1cbc 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { isSameDay } from 'date-fns';
+import { BarChart, DateRangePicker, FacetBox, FacetItem } from 'design-system';
 import { max } from 'lodash';
 import * as React from 'react';
-import { injectIntl, WrappedComponentProps } from 'react-intl';
-import BarChart from '../../../components/charts/BarChart';
-import DateRangeInput from '../../../components/controls/DateRangeInput';
-import FacetBox from '../../../components/facet/FacetBox';
-import FacetHeader from '../../../components/facet/FacetHeader';
-import FacetItem from '../../../components/facet/FacetItem';
+import { WrappedComponentProps, injectIntl } from 'react-intl';
 import { longFormatterOption } from '../../../components/intl/DateFormatter';
 import DateFromNow from '../../../components/intl/DateFromNow';
-import DateTimeFormatter, {
-  formatterOption as dateTimeFormatterOption,
-} from '../../../components/intl/DateTimeFormatter';
+import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import { parseDate } from '../../../helpers/dates';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure } from '../../../helpers/measures';
+import { MetricType } from '../../../types/metrics';
 import { Component, Dict } from '../../../types/types';
 import { Query } from '../utils';
 
@@ -52,7 +48,7 @@ interface Props {
   stats: Dict<number> | undefined;
 }
 
-export class CreationDateFacet extends React.PureComponent<Props & WrappedComponentProps> {
+export class CreationDateFacetClass extends React.PureComponent<Props & WrappedComponentProps> {
   property = 'createdAt';
 
   static defaultProps = {
@@ -66,14 +62,6 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
     this.props.createdInLast.length > 0 ||
     this.props.inNewCodePeriod;
 
-  handleHeaderClick = () => {
-    this.props.onToggle(this.property);
-  };
-
-  handleClear = () => {
-    this.resetTo({});
-  };
-
   resetTo = (changes: Partial<Query>) => {
     this.props.onChange({
       createdAfter: undefined,
@@ -85,51 +73,30 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
     });
   };
 
-  handleBarClick = ({
-    createdAfter,
-    createdBefore,
-  }: {
-    createdAfter: Date;
-    createdBefore?: Date;
-  }) => {
-    this.resetTo({ createdAfter, createdBefore });
-  };
-
   handlePeriodChange = ({ from, to }: { from?: Date; to?: Date }) => {
     this.resetTo({ createdAfter: from, createdBefore: to });
   };
 
   handlePeriodClick = (period: string) => this.resetTo({ createdInLast: period });
 
-  getValues() {
-    const { createdAfter, createdAfterIncludesTime, createdAt, createdBefore, createdInLast } =
-      this.props;
-    const { formatDate } = this.props.intl;
-    const values = [];
-    if (createdAfter) {
-      values.push(
-        formatDate(
-          createdAfter,
-          createdAfterIncludesTime ? dateTimeFormatterOption : longFormatterOption
-        )
-      );
-    }
-    if (createdAt) {
-      values.push(formatDate(createdAt, longFormatterOption));
-    }
-    if (createdBefore) {
-      values.push(formatDate(createdBefore, longFormatterOption));
-    }
-    if (createdInLast === '1w') {
-      values.push(translate('issues.facet.createdAt.last_week'));
-    }
-    if (createdInLast === '1m') {
-      values.push(translate('issues.facet.createdAt.last_month'));
-    }
-    if (createdInLast === '1y') {
-      values.push(translate('issues.facet.createdAt.last_year'));
+  getCount() {
+    const { createdAfter, createdAt, createdBefore, createdInLast } = this.props;
+
+    let count = 0;
+
+    if (createdInLast || createdAt) {
+      count = 1;
+    } else {
+      if (createdAfter) {
+        count++;
+      }
+
+      if (createdBefore) {
+        count++;
+      }
     }
-    return values;
+
+    return count;
   }
 
   renderBarChart() {
@@ -160,7 +127,7 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
       const tooltip = (
         // eslint-disable-next-line react/jsx-fragments
         <React.Fragment>
-          {formatMeasure(stats[start], 'SHORT_INT')}
+          {formatMeasure(stats[start], MetricType.ShortInteger)}
           <br />
           {formatDate(startDate, longFormatterOption)}
           {!isSameDay(tooltipEndDate, startDate) &&
@@ -169,7 +136,7 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
       );
       const description = translateWithParameters(
         'issues.facet.createdAt.bar_description',
-        formatMeasure(stats[start], 'SHORT_INT'),
+        formatMeasure(stats[start], MetricType.ShortInteger),
         formatDate(startDate, longFormatterOption),
         formatDate(tooltipEndDate, longFormatterOption)
       );
@@ -184,18 +151,20 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
       };
     });
 
-    const barsWidth = Math.floor(250 / data.length);
+    const barsWidth = Math.floor(270 / data.length);
     const width = barsWidth * data.length - 1 + 10;
 
     const maxValue = max(data.map((d) => d.y));
-    const xValues = data.map((d) => (d.y === maxValue ? formatMeasure(maxValue, 'SHORT_INT') : ''));
+    const xValues = data.map((d) =>
+      d.y === maxValue ? formatMeasure(maxValue, MetricType.ShortInteger) : ''
+    );
 
     return (
       <BarChart
         barsWidth={barsWidth - 1}
         data={data}
         height={75}
-        onBarClick={this.handleBarClick}
+        onBarClick={this.resetTo}
         padding={[25, 0, 5, 10]}
         width={width}
         xValues={xValues}
@@ -205,50 +174,67 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
 
   renderPeriodSelectors() {
     const { createdAfter, createdBefore } = this.props;
+
     return (
-      <div className="search-navigator-date-facet-selection">
-        <DateRangeInput
-          alignEndDateCalandarRight
-          onChange={this.handlePeriodChange}
-          value={{ from: createdAfter, to: createdBefore }}
-        />
-      </div>
+      <DateRangePicker
+        ariaNextMonthLabel={translate('next_')}
+        ariaPreviousMonthLabel={translate('previous_')}
+        clearButtonLabel={translate('clear')}
+        fromLabel={translate('start_date')}
+        onChange={this.handlePeriodChange}
+        separatorText={translate('to_')}
+        toLabel={translate('end_date')}
+        value={{ from: createdAfter, to: createdBefore }}
+      />
     );
   }
 
   renderPredefinedPeriods() {
     const { createdInLast } = this.props;
+
     return (
-      <div className="spacer-top issues-predefined-periods">
-        <FacetItem
-          active={!this.hasValue()}
-          name={translate('issues.facet.createdAt.all')}
-          onClick={this.handlePeriodClick}
-          tooltip={translate('issues.facet.createdAt.all')}
-          value=""
-        />
-
-        <FacetItem
-          active={createdInLast === '1w'}
-          name={translate('issues.facet.createdAt.last_week')}
-          onClick={this.handlePeriodClick}
-          tooltip={translate('issues.facet.createdAt.last_week')}
-          value="1w"
-        />
-        <FacetItem
-          active={createdInLast === '1m'}
-          name={translate('issues.facet.createdAt.last_month')}
-          onClick={this.handlePeriodClick}
-          tooltip={translate('issues.facet.createdAt.last_month')}
-          value="1m"
-        />
-        <FacetItem
-          active={createdInLast === '1y'}
-          name={translate('issues.facet.createdAt.last_year')}
-          onClick={this.handlePeriodClick}
-          tooltip={translate('issues.facet.createdAt.last_year')}
-          value="1y"
-        />
+      <div className="sw-flex sw-justify-start">
+        <div className="sw-flex sw-gap-1 sw-mt-2">
+          <FacetItem
+            active={!this.hasValue()}
+            className="it__search-navigator-facet"
+            name={translate('issues.facet.createdAt.all')}
+            onClick={this.handlePeriodClick}
+            small
+            tooltip={translate('issues.facet.createdAt.all')}
+            value=""
+          />
+
+          <FacetItem
+            active={createdInLast === '1w'}
+            className="it__search-navigator-facet"
+            name={translate('issues.facet.createdAt.last_week')}
+            onClick={this.handlePeriodClick}
+            small
+            tooltip={translate('issues.facet.createdAt.last_week')}
+            value="1w"
+          />
+
+          <FacetItem
+            active={createdInLast === '1m'}
+            className="it__search-navigator-facet"
+            name={translate('issues.facet.createdAt.last_month')}
+            onClick={this.handlePeriodClick}
+            small
+            tooltip={translate('issues.facet.createdAt.last_month')}
+            value="1m"
+          />
+
+          <FacetItem
+            active={createdInLast === '1y'}
+            className="it__search-navigator-facet"
+            name={translate('issues.facet.createdAt.last_year')}
+            onClick={this.handlePeriodClick}
+            small
+            tooltip={translate('issues.facet.createdAt.last_year')}
+            value="1y"
+          />
+        </div>
       </div>
     );
   }
@@ -279,8 +265,10 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
 
     return (
       <div>
-        {this.renderBarChart()}
+        <div className="sw-flex sw-justify-center">{this.renderBarChart()}</div>
+
         {this.renderPeriodSelectors()}
+
         {this.renderPredefinedPeriods()}
       </div>
     );
@@ -288,24 +276,32 @@ export class CreationDateFacet extends React.PureComponent<Props & WrappedCompon
 
   render() {
     const { fetching, open } = this.props;
+
+    const count = this.getCount();
     const headerId = `facet_${this.property}`;
 
     return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          fetching={fetching}
-          id={headerId}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={this.getValues()}
-        />
-
-        {open && this.renderInner()}
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={count}
+        countLabel={translateWithParameters('x_selected', count)}
+        data-property={this.property}
+        id={headerId}
+        loading={fetching}
+        name={translate('issues.facet', this.property)}
+        onClear={() => {
+          this.resetTo({});
+        }}
+        onClick={() => {
+          this.props.onToggle(this.property);
+        }}
+        open={open}
+      >
+        {this.renderInner()}
       </FacetBox>
     );
   }
 }
 
-export default injectIntl(CreationDateFacet);
+export const CreationDateFacet = injectIntl(CreationDateFacetClass);
index b4888017813d190c1a403f0504e8d7331118b32e..5b685f2cde241fc89d8948ce5ab9619ac37f0d5c 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { DirectoryIcon } from 'design-system';
 import { omit } from 'lodash';
 import * as React from 'react';
 import { getDirectories } from '../../../api/components';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { collapsePath } from '../../../helpers/path';
@@ -29,7 +29,9 @@ import { highlightTerm } from '../../../helpers/search';
 import { BranchLike } from '../../../types/branch-like';
 import { TreeComponentWithPath } from '../../../types/component';
 import { Facet } from '../../../types/issues';
+import { MetricKey } from '../../../types/metrics';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   branchLike?: BranchLike;
@@ -44,7 +46,7 @@ interface Props {
   stats: Facet | undefined;
 }
 
-export default class DirectoryFacet extends React.PureComponent<Props> {
+export class DirectoryFacet extends React.PureComponent<Props> {
   getFacetItemText = (path: string) => {
     return path;
   };
@@ -73,14 +75,15 @@ export default class DirectoryFacet extends React.PureComponent<Props> {
   };
 
   loadSearchResultCount = (directories: TreeComponentWithPath[]) => {
-    return this.props.loadSearchResultCount('directories', {
+    return this.props.loadSearchResultCount(MetricKey.directories, {
       directories: directories.map((directory) => directory.path),
     });
   };
 
   renderDirectory = (directory: React.ReactNode) => (
     <>
-      <QualifierIcon className="little-spacer-right" qualifier="DIR" />
+      <DirectoryIcon className="sw-mr-1" />
+
       {directory}
     </>
   );
@@ -107,8 +110,8 @@ export default class DirectoryFacet extends React.PureComponent<Props> {
         onSearch={this.handleSearch}
         onToggle={this.props.onToggle}
         open={this.props.open}
-        property="directories"
-        query={omit(this.props.query, 'directories')}
+        property={MetricKey.directories}
+        query={omit(this.props.query, MetricKey.directories)}
         renderFacetItem={this.renderFacetItem}
         renderSearchResult={this.renderSearchResult}
         searchPlaceholder={translate('search.search_for_directories')}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx
new file mode 100644 (file)
index 0000000..3710ed2
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * 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';
+
+export function FacetItemsColumns({ children }: React.PropsWithChildren<{}>) {
+  return (
+    <div className="it__search-navigator-facet-list sw-flex sw-flex-wrap sw-gap-1" role="list">
+      <div className="sw-gap-1 sw-grid sw-grid-cols-2 sw-w-full">{children}</div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx
new file mode 100644 (file)
index 0000000..536bc61
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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';
+
+export type FacetItemsListProps =
+  | {
+      children?: React.ReactNode;
+      labelledby: string;
+      label?: never;
+    }
+  | {
+      children?: React.ReactNode;
+      labelledby?: never;
+      label: string;
+    };
+
+export function FacetItemsList({ children, labelledby, label }: FacetItemsListProps) {
+  const props = labelledby ? { 'aria-labelledby': labelledby } : { 'aria-label': label };
+
+  return (
+    <div
+      className="it__search-navigator-facet-list sw-flex sw-flex-col sw-gap-1"
+      role="list"
+      {...props}
+    >
+      {children}
+    </div>
+  );
+}
index e315f8e04540d6b484422d09ad6ff140b15984c5..d3968c2130bb80e57ba12b0c82311204969869e2 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { FileIcon } from 'design-system';
 import { omit } from 'lodash';
 import * as React from 'react';
 import { getFiles } from '../../../api/components';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { collapsePath, splitPath } from '../../../helpers/path';
@@ -30,7 +30,9 @@ import { isDefined } from '../../../helpers/types';
 import { BranchLike } from '../../../types/branch-like';
 import { TreeComponentWithPath } from '../../../types/component';
 import { Facet } from '../../../types/issues';
+import { MetricKey } from '../../../types/metrics';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   branchLike?: BranchLike;
@@ -46,7 +48,8 @@ interface Props {
 }
 
 const MAX_PATH_LENGTH = 15;
-export default class FileFacet extends React.PureComponent<Props> {
+
+export class FileFacet extends React.PureComponent<Props> {
   getFacetItemText = (path: string) => {
     return path;
   };
@@ -75,7 +78,7 @@ export default class FileFacet extends React.PureComponent<Props> {
   };
 
   loadSearchResultCount = (files: TreeComponentWithPath[]) => {
-    return this.props.loadSearchResultCount('files', {
+    return this.props.loadSearchResultCount(MetricKey.files, {
       files: files
         .map((file) => {
           return file.path;
@@ -86,7 +89,8 @@ export default class FileFacet extends React.PureComponent<Props> {
 
   renderFile = (file: React.ReactNode) => (
     <>
-      <QualifierIcon className="little-spacer-right" qualifier="FIL" />
+      <FileIcon className="sw-mr-1" />
+
       {file}
     </>
   );
@@ -119,8 +123,8 @@ export default class FileFacet extends React.PureComponent<Props> {
         onSearch={this.handleSearch}
         onToggle={this.props.onToggle}
         open={this.props.open}
-        property="files"
-        query={omit(this.props.query, 'files')}
+        property={MetricKey.files}
+        query={omit(this.props.query, MetricKey.files)}
         renderFacetItem={this.renderFacetItem}
         renderSearchResult={this.renderSearchResult}
         searchPlaceholder={translate('search.search_for_files')}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx
new file mode 100644 (file)
index 0000000..ed8fb4e
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * 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 { BasicSeparator, DangerButtonSecondary, PageTitle } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  displayReset: boolean;
+  onReset: () => void;
+}
+
+export function FiltersHeader({ displayReset, onReset }: Props) {
+  return (
+    <div className="sw-mb-5">
+      <div className="sw-flex sw-h-9 sw-items-center sw-justify-between">
+        <PageTitle className="sw-body-md-highlight" text={translate('filters')} />
+
+        {displayReset && (
+          <DangerButtonSecondary onClick={onReset}>
+            {translate('clear_all_filters')}
+          </DangerButtonSecondary>
+        )}
+      </div>
+
+      <BasicSeparator className="sw-mt-4" />
+    </div>
+  );
+}
index 992f4a63b5c2b409a9a9b1eb60f557051726a525..28fc6c213397c8009f44a453ce76dd1ed536f313 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { omit, uniqBy } from 'lodash';
 import * as React from 'react';
 import withLanguagesContext from '../../../app/components/languages/withLanguagesContext';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
 import { translate } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
 import { Facet, ReferencedLanguage } from '../../../types/issues';
 import { Language, Languages } from '../../../types/languages';
 import { Dict } from '../../../types/types';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   fetching: boolean;
@@ -41,7 +42,7 @@ interface Props {
   stats: Dict<number> | undefined;
 }
 
-class LanguageFacet extends React.PureComponent<Props> {
+class LanguageFacetClass extends React.PureComponent<Props> {
   getLanguageName = (language: string) => {
     const { referencedLanguages } = this.props;
     return referencedLanguages[language] ? referencedLanguages[language].name : language;
@@ -49,10 +50,13 @@ class LanguageFacet extends React.PureComponent<Props> {
 
   handleSearch = (query: string) => {
     const options = this.getAllPossibleOptions();
+
     const results = options.filter((language) =>
       language.name.toLowerCase().includes(query.toLowerCase())
     );
+
     const paging = { pageIndex: 1, pageSize: results.length, total: results.length };
+
     return Promise.resolve({ paging, results });
   };
 
@@ -104,4 +108,4 @@ class LanguageFacet extends React.PureComponent<Props> {
   }
 }
 
-export default withLanguagesContext(LanguageFacet);
+export const LanguageFacet = withLanguagesContext(LanguageFacetClass);
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx
new file mode 100644 (file)
index 0000000..1c61747
--- /dev/null
@@ -0,0 +1,486 @@
+/*
+ * 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 { FacetBox, FacetItem, FlagMessage, InputSearch } from 'design-system';
+import { sortBy, without } from 'lodash';
+import * as React from 'react';
+import ListFooter from '../../../components/controls/ListFooter';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { formatMeasure } from '../../../helpers/measures';
+import { queriesEqual } from '../../../helpers/query';
+import { MetricType } from '../../../types/metrics';
+import { Dict, Paging, RawQuery } from '../../../types/types';
+import { FacetItemsList } from './FacetItemsList';
+import { ListStyleFacetFooter } from './ListStyleFacetFooter';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
+
+interface SearchResponse<S> {
+  maxResults?: boolean;
+  results: S[];
+  paging?: Paging;
+}
+
+export interface Props<S> {
+  disabled?: boolean;
+  facetHeader: string;
+  fetching: boolean;
+  getFacetItemText: (item: string) => string;
+  getSearchResultKey: (result: S) => string;
+  getSearchResultText: (result: S) => string;
+  getSortedItems?: () => string[];
+  inner?: boolean;
+  loadSearchResultCount?: (result: S[]) => Promise<Dict<number>>;
+  maxInitialItems: number;
+  maxItems: number;
+  minSearchLength: number;
+  onChange: (changes: Dict<string | string[]>) => void;
+  onClear?: () => void;
+  onItemClick?: (itemValue: string, multiple: boolean) => void;
+  onSearch: (query: string, page?: number) => Promise<SearchResponse<S>>;
+  onToggle: (property: string) => void;
+  open: boolean;
+  property: string;
+  query?: RawQuery;
+  renderFacetItem: (item: string) => string | JSX.Element;
+  renderSearchResult: (result: S, query: string) => React.ReactNode;
+  searchPlaceholder: string;
+  showLessAriaLabel?: string;
+  showMoreAriaLabel?: string;
+  stats: Dict<number> | undefined;
+  values: string[];
+}
+
+interface State<S> {
+  autoFocus: boolean;
+  query: string;
+  searching: boolean;
+  searchMaxResults?: boolean;
+  searchPaging?: Paging;
+  searchResults?: S[];
+  searchResultsCounts: Dict<number>;
+  showFullList: boolean;
+}
+
+export class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> {
+  mounted = false;
+
+  static defaultProps = {
+    getFacetItemText: (item: string) => item,
+    getSearchResultKey: (result: unknown) => result,
+    getSearchResultText: (result: unknown) => result,
+    maxInitialItems: 15,
+    maxItems: 100,
+    minSearchLength: 2,
+    renderFacetItem: (item: string) => item,
+    renderSearchResult: (result: unknown, _query: string) => result,
+  };
+
+  state: State<S> = {
+    autoFocus: false,
+    query: '',
+    searching: false,
+    searchResultsCounts: {},
+    showFullList: false,
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+  }
+
+  componentDidUpdate(prevProps: Props<S>) {
+    if (!prevProps.open && this.props.open) {
+      // focus search field *only* if it was manually open
+      this.setState({ autoFocus: true });
+    } else if (
+      (prevProps.open && !this.props.open) ||
+      !queriesEqual(prevProps.query || {}, this.props.query || {})
+    ) {
+      // reset state when closing the facet, or when query changes
+      this.setState({
+        query: '',
+        searchMaxResults: undefined,
+        searchResults: undefined,
+        searching: false,
+        searchResultsCounts: {},
+        showFullList: false,
+      });
+    } else if (
+      prevProps.stats !== this.props.stats &&
+      Object.keys(this.props.stats || {}).length < this.props.maxInitialItems
+    ) {
+      // show limited list if `stats` changed and there are less than 15 items
+      this.setState({ showFullList: false });
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  handleItemClick = (itemValue: string, multiple: boolean) => {
+    if (this.props.onItemClick) {
+      this.props.onItemClick(itemValue, multiple);
+    } else {
+      const { values } = this.props;
+
+      if (multiple) {
+        const newValue = sortBy(
+          values.includes(itemValue) ? without(values, itemValue) : [...values, itemValue]
+        );
+
+        this.props.onChange({ [this.props.property]: newValue });
+      } else {
+        this.props.onChange({
+          [this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue],
+        });
+      }
+    }
+  };
+
+  handleHeaderClick = () => {
+    this.props.onToggle(this.props.property);
+  };
+
+  handleClear = () => {
+    if (this.props.onClear) {
+      this.props.onClear();
+    } else {
+      this.props.onChange({ [this.props.property]: [] });
+    }
+  };
+
+  stopSearching = () => {
+    if (this.mounted) {
+      this.setState({ searching: false });
+    }
+  };
+
+  search = (query: string) => {
+    if (query.length >= this.props.minSearchLength) {
+      this.setState({ query, searching: true });
+
+      this.props
+        .onSearch(query)
+        .then(this.loadCountsForSearchResults)
+        .then(({ maxResults, paging, results, stats }) => {
+          if (this.mounted) {
+            this.setState((state) => ({
+              searching: false,
+              searchMaxResults: maxResults,
+              searchResults: results,
+              searchPaging: paging,
+              searchResultsCounts: { ...state.searchResultsCounts, ...stats },
+            }));
+          }
+        })
+        .catch(this.stopSearching);
+    } else {
+      this.setState({ query, searching: false, searchResults: [] });
+    }
+  };
+
+  searchMore = () => {
+    const { query, searchPaging, searchResults } = this.state;
+
+    if (query && searchResults && searchPaging) {
+      this.setState({ searching: true });
+
+      this.props
+        .onSearch(query, searchPaging.pageIndex + 1)
+        .then(this.loadCountsForSearchResults)
+        .then(({ paging, results, stats }) => {
+          if (this.mounted) {
+            this.setState((state) => ({
+              searching: false,
+              searchResults: [...searchResults, ...results],
+              searchPaging: paging,
+              searchResultsCounts: { ...state.searchResultsCounts, ...stats },
+            }));
+          }
+        })
+        .catch(this.stopSearching);
+    }
+  };
+
+  loadCountsForSearchResults = (response: SearchResponse<S>) => {
+    const { loadSearchResultCount = () => Promise.resolve({}) } = this.props;
+
+    const resultsToLoad = response.results.filter((result) => {
+      const key = this.props.getSearchResultKey(result);
+
+      return this.getStat(key) === undefined && this.state.searchResultsCounts[key] === undefined;
+    });
+
+    if (resultsToLoad.length > 0) {
+      return loadSearchResultCount(resultsToLoad).then((stats) => ({ ...response, stats }));
+    }
+
+    return { ...response, stats: {} };
+  };
+
+  getStat(item: string) {
+    const { stats } = this.props;
+
+    return stats?.[item];
+  }
+
+  getFacetHeaderId = (property: string) => {
+    return `facet_${property}`;
+  };
+
+  showFullList = () => {
+    this.setState({ showFullList: true });
+  };
+
+  hideFullList = () => {
+    this.setState({ showFullList: false });
+  };
+
+  renderList() {
+    const {
+      maxInitialItems,
+      maxItems,
+      property,
+      stats,
+      showMoreAriaLabel,
+      showLessAriaLabel,
+      values,
+    } = this.props;
+
+    if (!stats) {
+      return null;
+    }
+
+    const sortedItems = this.props.getSortedItems
+      ? this.props.getSortedItems()
+      : sortBy(
+          Object.keys(stats),
+          (key) => -stats[key],
+          (key) => this.props.getFacetItemText(key)
+        );
+
+    const limitedList = this.state.showFullList
+      ? sortedItems
+      : sortedItems.slice(0, maxInitialItems);
+
+    // make sure all selected items are displayed
+    const selectedBelowLimit = this.state.showFullList
+      ? []
+      : sortedItems.slice(maxInitialItems).filter((item) => values.includes(item));
+
+    const mightHaveMoreResults = sortedItems.length >= maxItems;
+
+    return (
+      <>
+        <FacetItemsList labelledby={this.getFacetHeaderId(property)}>
+          {limitedList.map((item) => (
+            <FacetItem
+              active={this.props.values.includes(item)}
+              className="it__search-navigator-facet"
+              key={item}
+              name={this.props.renderFacetItem(item)}
+              onClick={this.handleItemClick}
+              stat={formatFacetStat(this.getStat(item)) ?? 0}
+              tooltip={this.props.getFacetItemText(item)}
+              value={item}
+            />
+          ))}
+        </FacetItemsList>
+
+        {selectedBelowLimit.length > 0 && (
+          <>
+            <div className="note spacer-bottom text-center">⋯</div>
+
+            <FacetItemsList labelledby={this.getFacetHeaderId(property)}>
+              {selectedBelowLimit.map((item) => (
+                <FacetItem
+                  active={true}
+                  className="it__search-navigator-facet"
+                  key={item}
+                  name={this.props.renderFacetItem(item)}
+                  onClick={this.handleItemClick}
+                  stat={formatFacetStat(this.getStat(item)) ?? 0}
+                  tooltip={this.props.getFacetItemText(item)}
+                  value={item}
+                />
+              ))}
+            </FacetItemsList>
+          </>
+        )}
+
+        <ListStyleFacetFooter
+          nbShown={limitedList.length + selectedBelowLimit.length}
+          showLess={this.state.showFullList ? this.hideFullList : undefined}
+          showLessAriaLabel={showLessAriaLabel}
+          showMore={this.showFullList}
+          showMoreAriaLabel={showMoreAriaLabel}
+          total={sortedItems.length}
+        />
+
+        {mightHaveMoreResults && this.state.showFullList && (
+          <FlagMessage
+            ariaLabel={translate('facet_might_have_more_results')}
+            className="sw-flex sw-my-4"
+            variant="warning"
+          >
+            {translate('facet_might_have_more_results')}
+          </FlagMessage>
+        )}
+      </>
+    );
+  }
+
+  renderSearch() {
+    return (
+      <InputSearch
+        className="it__search-box-input sw-mb-4 sw-w-full"
+        autoFocus={this.state.autoFocus}
+        onChange={this.search}
+        placeholder={this.props.searchPlaceholder}
+        size="auto"
+        value={this.state.query}
+        searchInputAriaLabel={translate('search_verb')}
+        clearIconAriaLabel={translate('clear')}
+      />
+    );
+  }
+
+  renderSearchResults() {
+    const { property, showMoreAriaLabel } = this.props;
+    const { searching, searchMaxResults, searchResults, searchPaging } = this.state;
+
+    if (!searching && !searchResults?.length) {
+      return <div className="note spacer-bottom">{translate('no_results')}</div>;
+    }
+
+    if (!searchResults) {
+      // initial search
+      return null;
+    }
+
+    return (
+      <>
+        <FacetItemsList labelledby={this.getFacetHeaderId(property)}>
+          {searchResults.map((result) => this.renderSearchResult(result))}
+        </FacetItemsList>
+
+        {searchMaxResults && (
+          <FlagMessage
+            ariaLabel={translate('facet_might_have_more_results')}
+            className="sw-flex sw-my-4"
+            variant="warning"
+          >
+            {translate('facet_might_have_more_results')}
+          </FlagMessage>
+        )}
+
+        {searchPaging && (
+          <ListFooter
+            className="sw-mb-2"
+            count={searchResults.length}
+            loadMore={this.searchMore}
+            loadMoreAriaLabel={showMoreAriaLabel}
+            ready={!searching}
+            total={searchPaging.total}
+            useMIUIButtons={true}
+          />
+        )}
+      </>
+    );
+  }
+
+  renderSearchResult(result: S) {
+    const key = this.props.getSearchResultKey(result);
+    const active = this.props.values.includes(key);
+    const stat = formatFacetStat(this.getStat(key) ?? this.state.searchResultsCounts[key]) ?? 0;
+
+    return (
+      <FacetItem
+        active={active}
+        className="it__search-navigator-facet"
+        key={key}
+        name={this.props.renderSearchResult(result, this.state.query)}
+        onClick={this.handleItemClick}
+        stat={stat}
+        tooltip={this.props.getSearchResultText(result)}
+        value={key}
+      />
+    );
+  }
+
+  render() {
+    const {
+      disabled,
+      facetHeader,
+      fetching,
+      inner,
+      open,
+      property,
+      stats = {},
+      values: propsValues,
+    } = this.props;
+
+    const { query, searching, searchResults } = this.state;
+
+    const values = propsValues.map((item) => this.props.getFacetItemText(item));
+
+    const loadingResults =
+      query !== '' && searching && (searchResults === undefined || searchResults.length === 0);
+
+    const showList = !query || loadingResults;
+
+    const nbSelectableItems = Object.keys(stats).length;
+    const nbSelectedItems = values.length;
+
+    return (
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={nbSelectedItems}
+        countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+        disabled={disabled}
+        id={this.getFacetHeaderId(property)}
+        inner={inner}
+        loading={fetching}
+        name={facetHeader}
+        onClear={this.handleClear}
+        onClick={disabled ? undefined : this.handleHeaderClick}
+        open={open && !disabled}
+      >
+        {!disabled && (
+          <span className="it__search-navigator-facet-list">
+            {this.renderSearch()}
+
+            {showList ? this.renderList() : this.renderSearchResults()}
+
+            <MultipleSelectionHint
+              nbSelectableItems={nbSelectableItems}
+              nbSelectedItems={nbSelectedItems}
+            />
+          </span>
+        )}
+      </FacetBox>
+    );
+  }
+}
+
+function formatFacetStat(stat: number | undefined) {
+  return stat && formatMeasure(stat, MetricType.ShortInteger);
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx
new file mode 100644 (file)
index 0000000..159b292
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * 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 { useTheme } from '@emotion/react';
+import { BaseLink, Theme, themeColor } from 'design-system';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { formatMeasure } from '../../../helpers/measures';
+import { MetricType } from '../../../types/metrics';
+
+export interface Props {
+  nbShown: number;
+  showLess?: () => void;
+  showLessAriaLabel?: string;
+  showMore: () => void;
+  showMoreAriaLabel?: string;
+  total: number;
+}
+
+export function ListStyleFacetFooter({
+  nbShown,
+  showLess,
+  showLessAriaLabel,
+  showMore,
+  showMoreAriaLabel,
+  total,
+}: Props) {
+  const theme = useTheme() as Theme;
+
+  const hasMore = total > nbShown;
+  const allShown = Boolean(total && total === nbShown);
+
+  return (
+    <div
+      className="sw-body-xs sw-mb-2 sw-mt-2 sw-text-center"
+      style={{ color: themeColor('graphCursorLineColor')({ theme }) }}
+    >
+      {translateWithParameters('x_show', formatMeasure(nbShown, MetricType.Integer))}
+
+      {hasMore && (
+        <BaseLink
+          aria-label={showMoreAriaLabel}
+          className="sw-ml-2"
+          onClick={(e) => {
+            e.preventDefault();
+            showMore();
+          }}
+          to="#"
+        >
+          {translate('show_more')}
+        </BaseLink>
+      )}
+
+      {showLess && allShown && (
+        <BaseLink
+          aria-label={showLessAriaLabel}
+          className="sw-ml-2"
+          onClick={(e) => {
+            e.preventDefault();
+            showLess();
+          }}
+          to="#"
+        >
+          {translate('show_less')}
+        </BaseLink>
+      )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx
new file mode 100644 (file)
index 0000000..e7bcaa4
--- /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 { KeyboardHint } from 'design-system';
+import * as React from 'react';
+import { translate } from '../../../helpers/l10n';
+
+export function MultipleSelectionHint({
+  nbSelectableItems,
+  nbSelectedItems,
+}: {
+  nbSelectableItems: number;
+  nbSelectedItems: number;
+}) {
+  return nbSelectedItems > 0 && nbSelectedItems < nbSelectableItems ? (
+    <div className="sw-pt-4">
+      <KeyboardHint command={translate('shortcuts.section.global.facets.multiselection')} />
+    </div>
+  ) : null;
+}
index c8f0745e2646327995f6e633a7e0ecbe24b30838..346b48b23468ef8a6537e88bd2aa3119b6442d4d 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { BasicSeparator, FacetItem } from 'design-system';
 import * as React from 'react';
-import FacetBox from '../../../components/facet/FacetBox';
-import FacetItem from '../../../components/facet/FacetItem';
-import FacetItemsList from '../../../components/facet/FacetItemsList';
 import { translate } from '../../../helpers/l10n';
-import { Dict } from '../../../types/types';
-import { formatFacetStat, Query } from '../utils';
+import { MeasuresPanelTabs } from '../../overview/branches/MeasuresPanel';
+import { Query } from '../utils';
+import { FacetItemsList } from './FacetItemsList';
 
 export interface PeriodFilterProps {
-  fetching: boolean;
   onChange: (changes: Partial<Query>) => void;
-  stats: Dict<number> | undefined;
   newCodeSelected: boolean;
 }
 
@@ -38,10 +36,9 @@ enum Period {
 
 const PROPERTY = 'period';
 
-export default function PeriodFilter(props: PeriodFilterProps) {
-  const { fetching, newCodeSelected, stats = {} } = props;
+export function PeriodFilter(props: PeriodFilterProps) {
+  const { newCodeSelected, onChange } = props;
 
-  const { onChange } = props;
   const handleClick = React.useCallback(() => {
     // We need to clear creation date filters they conflict with the new code period
     onChange({
@@ -54,17 +51,16 @@ export default function PeriodFilter(props: PeriodFilterProps) {
   }, [newCodeSelected, onChange]);
 
   return (
-    <FacetBox property={PROPERTY}>
-      <FacetItemsList label={translate('issues.facet', PROPERTY)}>
-        <FacetItem
-          active={newCodeSelected}
-          loading={fetching}
-          name={translate('issues.new_code')}
-          onClick={handleClick}
-          stat={formatFacetStat(stats[Period.NewCode])}
-          value={Period.NewCode}
-        />
-      </FacetItemsList>
-    </FacetBox>
+    <FacetItemsList label={translate('issues.facet', PROPERTY)}>
+      <FacetItem
+        active={newCodeSelected}
+        className="it__search-navigator-facet"
+        name={translate('issues.new_code')}
+        onClick={handleClick}
+        value={newCodeSelected ? MeasuresPanelTabs.New : MeasuresPanelTabs.Overall}
+      />
+
+      <BasicSeparator className="sw-mb-5 sw-mt-4" />
+    </FacetItemsList>
   );
 }
index 4c738b2f4fac86f6af144ae60c913d07e08f036b..03e06c132ee70336ca01ebb341844c5dd6300233 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { ProjectIcon } from 'design-system';
 import { omit } from 'lodash';
 import * as React from 'react';
 import { getTree, searchProjects } from '../../../api/components';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
 import { translate } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
 import { ComponentQualifier } from '../../../types/component';
 import { Facet, ReferencedComponent } from '../../../types/issues';
+import { MetricKey } from '../../../types/metrics';
 import { Component, Dict, Paging } from '../../../types/types';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   component: Component | undefined;
@@ -47,12 +49,13 @@ interface SearchedProject {
   name: string;
 }
 
-export default class ProjectFacet extends React.PureComponent<Props> {
+export class ProjectFacet extends React.PureComponent<Props> {
   handleSearch = (
     query: string,
     page = 1
   ): Promise<{ results: SearchedProject[]; paging: Paging }> => {
     const { component } = this.props;
+
     if (
       component &&
       [
@@ -91,11 +94,12 @@ export default class ProjectFacet extends React.PureComponent<Props> {
 
   getProjectName = (project: string) => {
     const { referencedComponents } = this.props;
+
     return referencedComponents[project] ? referencedComponents[project].name : project;
   };
 
   loadSearchResultCount = (projects: SearchedProject[]) => {
-    return this.props.loadSearchResultCount('projects', {
+    return this.props.loadSearchResultCount(MetricKey.projects, {
       projects: projects.map((project) => project.key),
     });
   };
@@ -103,7 +107,8 @@ export default class ProjectFacet extends React.PureComponent<Props> {
   renderFacetItem = (projectKey: string) => {
     return (
       <span>
-        <QualifierIcon className="little-spacer-right" qualifier={ComponentQualifier.Project} />
+        <ProjectIcon className="sw-mr-1" />
+
         {this.getProjectName(projectKey)}
       </span>
     );
@@ -111,7 +116,8 @@ export default class ProjectFacet extends React.PureComponent<Props> {
 
   renderSearchResult = (project: Pick<SearchedProject, 'name'>, term: string) => (
     <>
-      <QualifierIcon className="little-spacer-right" qualifier={ComponentQualifier.Project} />
+      <ProjectIcon className="sw-mr-1" />
+
       {highlightTerm(project.name, term)}
     </>
   );
@@ -129,8 +135,8 @@ export default class ProjectFacet extends React.PureComponent<Props> {
         onSearch={this.handleSearch}
         onToggle={this.props.onToggle}
         open={this.props.open}
-        property="projects"
-        query={omit(this.props.query, 'projects')}
+        property={MetricKey.projects}
+        query={omit(this.props.query, MetricKey.projects)}
         renderFacetItem={this.renderFacetItem}
         renderSearchResult={this.renderSearchResult}
         searchPlaceholder={translate('search.search_for_projects')}
index 26b1f746c9caa33f6bf4c3004a1517887f1b8b0c..30b12397f3a2f79b6d0fe3d1301b2dd8964bf379 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { FacetBox, FacetItem } from 'design-system';
 import { orderBy, 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 { IssueResolution } from '../../../types/issues';
+import { RESOLUTIONS } from '../../../helpers/constants';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query, formatFacetStat } from '../utils';
+import { FacetItemsColumns } from './FacetItemsColumns';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface Props {
   fetching: boolean;
@@ -39,15 +38,7 @@ interface Props {
   stats: Dict<number> | undefined;
 }
 
-const RESOLUTIONS = [
-  IssueResolution.Unresolved,
-  IssueResolution.FalsePositive,
-  IssueResolution.Fixed,
-  IssueResolution.Removed,
-  IssueResolution.WontFix,
-];
-
-export default class ResolutionFacet extends React.PureComponent<Props> {
+export class ResolutionFacet extends React.PureComponent<Props> {
   property = 'resolutions';
 
   static defaultProps = {
@@ -56,6 +47,7 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
 
   handleItemClick = (itemValue: string, multiple: boolean) => {
     const { resolutions } = this.props;
+
     if (itemValue === '') {
       // unresolved
       this.props.onChange({ resolved: !this.props.resolved, resolutions: [] });
@@ -65,6 +57,7 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
           ? without(resolutions, itemValue)
           : [...resolutions, itemValue]
       );
+
       this.props.onChange({ resolved: true, [this.property]: newValue });
     } else {
       this.props.onChange({
@@ -93,6 +86,7 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
 
   getStat(resolution: string) {
     const { stats } = this.props;
+
     return stats ? stats[resolution] : undefined;
   }
 
@@ -103,11 +97,11 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
     return (
       <FacetItem
         active={active}
-        halfWidth
+        className="it__search-navigator-facet"
         key={resolution}
         name={this.getFacetItemName(resolution)}
         onClick={this.handleItemClick}
-        stat={formatFacetStat(stat)}
+        stat={formatFacetStat(stat) ?? 0}
         tooltip={this.getFacetItemName(resolution)}
         value={resolution}
       />
@@ -115,33 +109,34 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
   };
 
   render() {
-    const { fetching, open, resolutions, stats = {} } = this.props;
-    const values = resolutions.map((resolution) => this.getFacetItemName(resolution));
+    const { fetching, open, resolutions } = this.props;
+
+    // below: -1 because "Unresolved" is mutually exclusive with the rest
+    const nbSelectableItems = RESOLUTIONS.filter(this.getStat.bind(this)).length - 1;
+
+    const nbSelectedItems = resolutions.length;
     const headerId = `facet_${this.property}`;
 
     return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          fetching={fetching}
-          id={headerId}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={values}
-        />
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={nbSelectedItems}
+        countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+        data-property={this.property}
+        id={headerId}
+        loading={fetching}
+        name={translate('issues.facet', this.property)}
+        onClear={this.handleClear}
+        onClick={this.handleHeaderClick}
+        open={open}
+      >
+        <FacetItemsColumns>{RESOLUTIONS.map(this.renderItem)}</FacetItemsColumns>
 
-        {open && (
-          <>
-            <FacetItemsList labelledby={headerId}>
-              {RESOLUTIONS.map(this.renderItem)}
-            </FacetItemsList>
-            <MultipleSelectionHint
-              options={Object.keys(stats).length}
-              values={resolutions.length}
-            />
-          </>
-        )}
+        <MultipleSelectionHint
+          nbSelectableItems={nbSelectableItems}
+          nbSelectedItems={nbSelectedItems}
+        />
       </FacetBox>
     );
   }
index 2a0256818f421be012df063db8c0331bb0a7036c..62afc0e44dc2d90408731f8ec045ea4824ebe093 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { omit } from 'lodash';
 import * as React from 'react';
 import { searchRules } from '../../../api/rules';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
 import { ISSUE_TYPES } from '../../../helpers/constants';
 import { translate } from '../../../helpers/l10n';
 import { Facet, IssueType, ReferencedRule } from '../../../types/issues';
 import { Dict, Rule } from '../../../types/types';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   fetching: boolean;
@@ -38,9 +39,10 @@ interface Props {
   stats: Dict<number> | undefined;
 }
 
-export default class RuleFacet extends React.PureComponent<Props> {
+export class RuleFacet extends React.PureComponent<Props> {
   handleSearch = (query: string, page = 1) => {
     const { languages, types } = this.props.query;
+
     return searchRules({
       f: 'name,langName',
       languages: languages.length ? languages.join() : undefined,
@@ -64,6 +66,7 @@ export default class RuleFacet extends React.PureComponent<Props> {
 
   getRuleName = (ruleKey: string) => {
     const rule = this.props.referencedRules[ruleKey];
+
     return rule ? this.formatRuleName(rule.name, rule.langName) : ruleKey;
   };
 
index 54bf842674a0c0ad000fe3b853e566ff2738834c..1624faa9cbb28382269f22ddbad2280a551035c9 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { FacetBox, FacetItem, FileIcon, TestFileIcon } from 'design-system';
 import { 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 QualifierIcon from '../../../components/icons/QualifierIcon';
 import { SOURCE_SCOPES } from '../../../helpers/constants';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
-import { formatFacetStat, Query } from '../utils';
+import { Query, formatFacetStat } from '../utils';
+import { FacetItemsList } from './FacetItemsList';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 export interface ScopeFacetProps {
   fetching: boolean;
@@ -39,66 +37,64 @@ export interface ScopeFacetProps {
   stats: Dict<number> | undefined;
 }
 
-export default function ScopeFacet(props: ScopeFacetProps) {
+export function ScopeFacet(props: ScopeFacetProps) {
   const { fetching, open, scopes = [], stats = {} } = props;
-  const values = scopes.map((scope) => translate('issue.scope', scope));
 
+  const nbSelectableItems = SOURCE_SCOPES.filter(({ scope }) => stats[scope]).length;
+  const nbSelectedItems = scopes.length;
   const property = 'scopes';
   const headerId = `facet_${property}`;
 
   return (
-    <FacetBox property={property}>
-      <FacetHeader
-        fetching={fetching}
-        id={headerId}
-        name={translate('issues.facet.scopes')}
-        onClear={() => props.onChange({ scopes: [] })}
-        onClick={() => props.onToggle('scopes')}
-        open={open}
-        values={values}
-      />
-
-      {open && (
-        <>
-          <FacetItemsList labelledby={headerId}>
-            {SOURCE_SCOPES.map(({ scope, qualifier }) => {
-              const active = scopes.includes(scope);
-              const stat = stats[scope];
+    <FacetBox
+      className="it__search-navigator-facet-box it__search-navigator-facet-header"
+      clearIconLabel={translate('clear')}
+      count={nbSelectedItems}
+      countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+      data-property={property}
+      id={headerId}
+      loading={fetching}
+      name={translate('issues.facet.scopes')}
+      onClear={() => props.onChange({ scopes: [] })}
+      onClick={() => props.onToggle('scopes')}
+      open={open}
+    >
+      <>
+        <FacetItemsList labelledby={headerId}>
+          {SOURCE_SCOPES.map(({ scope }) => {
+            const active = scopes.includes(scope);
+            const stat = stats[scope];
 
-              return (
-                <FacetItem
-                  active={active}
-                  key={scope}
-                  name={
-                    <span className="display-flex-center">
-                      <QualifierIcon
-                        className="little-spacer-right"
-                        qualifier={qualifier}
-                        aria-hidden
-                      />{' '}
-                      {translate('issue.scope', scope)}
-                    </span>
+            return (
+              <FacetItem
+                active={active}
+                className="it__search-navigator-facet"
+                icon={{ MAIN: <FileIcon />, TEST: <TestFileIcon /> }[scope]}
+                key={scope}
+                name={translate('issue.scope', scope)}
+                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],
+                    });
                   }
-                  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} />
-        </>
-      )}
+                }}
+                stat={formatFacetStat(stat) ?? 0}
+                value={scope}
+              />
+            );
+          })}
+        </FacetItemsList>
+
+        <MultipleSelectionHint
+          nbSelectableItems={nbSelectableItems}
+          nbSelectedItems={nbSelectedItems}
+        />
+      </>
     </FacetBox>
   );
 }
index e840940f276e73792475dc148097de4fd1a43942..cdf9b373ea6d1a0a15dea8e4f26d517cb4cc4f85 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import {
+  FacetBox,
+  FacetItem,
+  SeverityBlockerIcon,
+  SeverityCriticalIcon,
+  SeverityInfoIcon,
+  SeverityMajorIcon,
+  SeverityMinorIcon,
+} from 'design-system';
 import { orderBy, 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 SeverityHelper from '../../../components/shared/SeverityHelper';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query, formatFacetStat } from '../utils';
+import { FacetItemsColumns } from './FacetItemsColumns';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface Props {
   fetching: boolean;
@@ -38,9 +44,10 @@ interface Props {
   stats: Dict<number> | undefined;
 }
 
+// can't user SEVERITIES from 'helpers/constants' because of different order
 const SEVERITIES = ['BLOCKER', 'MINOR', 'CRITICAL', 'INFO', 'MAJOR'];
 
-export default class SeverityFacet extends React.PureComponent<Props> {
+export class SeverityFacet extends React.PureComponent<Props> {
   property = 'severities';
 
   static defaultProps = {
@@ -49,10 +56,12 @@ export default class SeverityFacet extends React.PureComponent<Props> {
 
   handleItemClick = (itemValue: string, multiple: boolean) => {
     const { severities } = this.props;
+
     if (multiple) {
       const newValue = orderBy(
         severities.includes(itemValue) ? without(severities, itemValue) : [...severities, itemValue]
       );
+
       this.props.onChange({ [this.property]: newValue });
     } else {
       this.props.onChange({
@@ -71,6 +80,7 @@ export default class SeverityFacet extends React.PureComponent<Props> {
 
   getStat(severity: string) {
     const { stats } = this.props;
+
     return stats ? stats[severity] : undefined;
   }
 
@@ -81,40 +91,52 @@ export default class SeverityFacet extends React.PureComponent<Props> {
     return (
       <FacetItem
         active={active}
-        halfWidth
+        className="it__search-navigator-facet"
+        icon={
+          {
+            BLOCKER: <SeverityBlockerIcon />,
+            CRITICAL: <SeverityCriticalIcon />,
+            INFO: <SeverityInfoIcon />,
+            MAJOR: <SeverityMajorIcon />,
+            MINOR: <SeverityMinorIcon />,
+          }[severity]
+        }
         key={severity}
-        name={<SeverityHelper severity={severity} />}
+        name={translate('severity', severity)}
         onClick={this.handleItemClick}
-        stat={formatFacetStat(stat)}
-        tooltip={translate('severity', severity)}
+        stat={formatFacetStat(stat) ?? 0}
         value={severity}
       />
     );
   };
 
   render() {
-    const { fetching, open, severities, stats = {} } = this.props;
-    const values = severities.map((severity) => translate('severity', severity));
+    const { fetching, open, severities } = this.props;
+
     const headerId = `facet_${this.property}`;
+    const nbSelectableItems = SEVERITIES.filter(this.getStat.bind(this)).length;
+    const nbSelectedItems = severities.length;
 
     return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          fetching={fetching}
-          id={headerId}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={values}
-        />
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={nbSelectedItems}
+        countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+        data-property={this.property}
+        id={headerId}
+        loading={fetching}
+        name={translate('issues.facet', this.property)}
+        onClear={this.handleClear}
+        onClick={this.handleHeaderClick}
+        open={open}
+      >
+        <FacetItemsColumns>{SEVERITIES.map(this.renderItem)}</FacetItemsColumns>
 
-        {open && (
-          <>
-            <FacetItemsList labelledby={headerId}>{SEVERITIES.map(this.renderItem)}</FacetItemsList>
-            <MultipleSelectionHint options={Object.keys(stats).length} values={severities.length} />
-          </>
-        )}
+        <MultipleSelectionHint
+          nbSelectableItems={nbSelectableItems}
+          nbSelectedItems={nbSelectedItems}
+        />
       </FacetBox>
     );
   }
index 3b96cac8bbc0e5a8ca39fc135d4fc94b71a3a88a..005897e31d2afa6b438503cd2e34aded71dbfaa5 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { BasicSeparator } from 'design-system';
 import * as React from 'react';
 import withAppStateContext from '../../../app/components/app-state/withAppStateContext';
 import { isBranch, isPullRequest } from '../../../helpers/branch-like';
@@ -39,23 +41,23 @@ import { GlobalSettingKeys } from '../../../types/settings';
 import { Component, Dict } from '../../../types/types';
 import { UserBase } from '../../../types/users';
 import { Query } from '../utils';
-import AssigneeFacet from './AssigneeFacet';
-import AuthorFacet from './AuthorFacet';
-import CreationDateFacet from './CreationDateFacet';
-import DirectoryFacet from './DirectoryFacet';
-import FileFacet from './FileFacet';
-import LanguageFacet from './LanguageFacet';
-import PeriodFilter from './PeriodFilter';
-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';
-import TagFacet from './TagFacet';
-import TypeFacet from './TypeFacet';
-import VariantFacet from './VariantFacet';
+import { AssigneeFacet } from './AssigneeFacet';
+import { AuthorFacet } from './AuthorFacet';
+import { CreationDateFacet } from './CreationDateFacet';
+import { DirectoryFacet } from './DirectoryFacet';
+import { FileFacet } from './FileFacet';
+import { LanguageFacet } from './LanguageFacet';
+import { PeriodFilter } from './PeriodFilter';
+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';
+import { TagFacet } from './TagFacet';
+import { TypeFacet } from './TypeFacet';
+import { VariantFacet } from './VariantFacet';
 
 export interface Props {
   appState: AppState;
@@ -78,15 +80,18 @@ export interface Props {
   referencedUsers: Dict<UserBase>;
 }
 
-export class Sidebar extends React.PureComponent<Props> {
+export class SidebarClass extends React.PureComponent<Props> {
   renderComponentFacets() {
     const { component, facets, loadingFacets, openFacets, query, branchLike, showVariantsFilter } =
       this.props;
+
     const hasFileOrDirectory =
       !isApplication(component?.qualifier) && !isPortfolioLike(component?.qualifier);
+
     if (!component || !hasFileOrDirectory) {
       return null;
     }
+
     const commonProps = {
       componentKey: component.key,
       loadSearchResultCount: this.props.loadSearchResultCount,
@@ -94,27 +99,40 @@ export class Sidebar extends React.PureComponent<Props> {
       onToggle: this.props.onFacetToggle,
       query,
     };
+
     return (
       <>
         {showVariantsFilter && isProject(component?.qualifier) && (
-          <VariantFacet
-            fetching={loadingFacets.codeVariants === true}
-            open={!!openFacets.codeVariants}
-            stats={facets.codeVariants}
-            values={query.codeVariants}
-            {...commonProps}
-          />
+          <>
+            <BasicSeparator className="sw-my-4" />
+
+            <VariantFacet
+              fetching={loadingFacets.codeVariants === true}
+              open={!!openFacets.codeVariants}
+              stats={facets.codeVariants}
+              values={query.codeVariants}
+              {...commonProps}
+            />
+          </>
         )}
+
         {component.qualifier !== ComponentQualifier.Directory && (
-          <DirectoryFacet
-            branchLike={branchLike}
-            directories={query.directories}
-            fetching={loadingFacets.directories === true}
-            open={!!openFacets.directories}
-            stats={facets.directories}
-            {...commonProps}
-          />
+          <>
+            <BasicSeparator className="sw-my-4" />
+
+            <DirectoryFacet
+              branchLike={branchLike}
+              directories={query.directories}
+              fetching={loadingFacets.directories === true}
+              open={!!openFacets.directories}
+              stats={facets.directories}
+              {...commonProps}
+            />
+          </>
         )}
+
+        <BasicSeparator className="sw-my-4" />
+
         <FileFacet
           branchLike={branchLike}
           fetching={loadingFacets.files === true}
@@ -148,18 +166,17 @@ export class Sidebar extends React.PureComponent<Props> {
 
     const displayPeriodFilter = component !== undefined && !isPortfolioLike(component.qualifier);
     const displayProjectsFacet = !component || isView(component.qualifier);
-    const displayAuthorFacet = !component || component.qualifier !== 'DEV';
+    const displayAuthorFacet = !component || component.qualifier !== ComponentQualifier.Developper;
 
     return (
       <>
         {displayPeriodFilter && (
           <PeriodFilter
-            fetching={this.props.loadingFacets.period === true}
             onChange={this.props.onFilterChange}
-            stats={facets.period}
             newCodeSelected={query.inNewCodePeriod}
           />
         )}
+
         <TypeFacet
           fetching={this.props.loadingFacets.types === true}
           onChange={this.props.onFilterChange}
@@ -168,6 +185,9 @@ export class Sidebar extends React.PureComponent<Props> {
           stats={facets.types}
           types={query.types}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <SeverityFacet
           fetching={this.props.loadingFacets.severities === true}
           onChange={this.props.onFilterChange}
@@ -176,6 +196,9 @@ export class Sidebar extends React.PureComponent<Props> {
           severities={query.severities}
           stats={facets.severities}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <ScopeFacet
           fetching={this.props.loadingFacets.scopes === true}
           onChange={this.props.onFilterChange}
@@ -184,6 +207,9 @@ export class Sidebar extends React.PureComponent<Props> {
           stats={facets.scopes}
           scopes={query.scopes}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <ResolutionFacet
           fetching={this.props.loadingFacets.resolutions === true}
           onChange={this.props.onFilterChange}
@@ -193,6 +219,9 @@ export class Sidebar extends React.PureComponent<Props> {
           resolved={query.resolved}
           stats={facets.resolutions}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <StatusFacet
           fetching={this.props.loadingFacets.statuses === true}
           onChange={this.props.onFilterChange}
@@ -201,6 +230,9 @@ export class Sidebar extends React.PureComponent<Props> {
           stats={facets.statuses}
           statuses={query.statuses}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <StandardFacet
           cwe={query.cwe}
           cweOpen={!!openFacets.cwe}
@@ -224,6 +256,9 @@ export class Sidebar extends React.PureComponent<Props> {
           sonarsourceSecurityOpen={!!openFacets.sonarsourceSecurity}
           sonarsourceSecurityStats={facets.sonarsourceSecurity}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <CreationDateFacet
           component={component}
           createdAfter={query.createdAfter}
@@ -238,6 +273,9 @@ export class Sidebar extends React.PureComponent<Props> {
           inNewCodePeriod={query.inNewCodePeriod}
           stats={facets.createdAt}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <LanguageFacet
           fetching={this.props.loadingFacets.languages === true}
           loadSearchResultCount={this.props.loadSearchResultCount}
@@ -249,6 +287,9 @@ export class Sidebar extends React.PureComponent<Props> {
           selectedLanguages={query.languages}
           stats={facets.languages}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <RuleFacet
           fetching={this.props.loadingFacets.rules === true}
           loadSearchResultCount={this.props.loadSearchResultCount}
@@ -259,6 +300,9 @@ export class Sidebar extends React.PureComponent<Props> {
           referencedRules={this.props.referencedRules}
           stats={facets.rules}
         />
+
+        <BasicSeparator className="sw-my-4" />
+
         <TagFacet
           component={component}
           branch={branch}
@@ -271,51 +315,67 @@ export class Sidebar extends React.PureComponent<Props> {
           stats={facets.tags}
           tags={query.tags}
         />
+
         {displayProjectsFacet && (
-          <ProjectFacet
-            component={component}
-            fetching={this.props.loadingFacets.projects === true}
-            loadSearchResultCount={this.props.loadSearchResultCount}
-            onChange={this.props.onFilterChange}
-            onToggle={this.props.onFacetToggle}
-            open={!!openFacets.projects}
-            projects={query.projects}
-            query={query}
-            referencedComponents={this.props.referencedComponentsByKey}
-            stats={facets.projects}
-          />
+          <>
+            <BasicSeparator className="sw-my-4" />
+
+            <ProjectFacet
+              component={component}
+              fetching={this.props.loadingFacets.projects === true}
+              loadSearchResultCount={this.props.loadSearchResultCount}
+              onChange={this.props.onFilterChange}
+              onToggle={this.props.onFacetToggle}
+              open={!!openFacets.projects}
+              projects={query.projects}
+              query={query}
+              referencedComponents={this.props.referencedComponentsByKey}
+              stats={facets.projects}
+            />
+          </>
         )}
+
         {this.renderComponentFacets()}
+
         {!this.props.myIssues && !disableDeveloperAggregatedInfo && (
-          <AssigneeFacet
-            assigned={query.assigned}
-            assignees={query.assignees}
-            fetching={this.props.loadingFacets.assignees === true}
-            loadSearchResultCount={this.props.loadSearchResultCount}
-            onChange={this.props.onFilterChange}
-            onToggle={this.props.onFacetToggle}
-            open={!!openFacets.assignees}
-            query={query}
-            referencedUsers={this.props.referencedUsers}
-            stats={facets.assignees}
-          />
+          <>
+            <BasicSeparator className="sw-my-4" />
+
+            <AssigneeFacet
+              assigned={query.assigned}
+              assignees={query.assignees}
+              fetching={this.props.loadingFacets.assignees === true}
+              loadSearchResultCount={this.props.loadSearchResultCount}
+              onChange={this.props.onFilterChange}
+              onToggle={this.props.onFacetToggle}
+              open={!!openFacets.assignees}
+              query={query}
+              referencedUsers={this.props.referencedUsers}
+              stats={facets.assignees}
+            />
+          </>
         )}
+
         {displayAuthorFacet && !disableDeveloperAggregatedInfo && (
-          <AuthorFacet
-            author={query.author}
-            component={component}
-            fetching={this.props.loadingFacets.author === true}
-            loadSearchResultCount={this.props.loadSearchResultCount}
-            onChange={this.props.onFilterChange}
-            onToggle={this.props.onFacetToggle}
-            open={!!openFacets.author}
-            query={query}
-            stats={facets.author}
-          />
+          <>
+            <BasicSeparator className="sw-my-4" />
+
+            <AuthorFacet
+              author={query.author}
+              component={component}
+              fetching={this.props.loadingFacets.author === true}
+              loadSearchResultCount={this.props.loadSearchResultCount}
+              onChange={this.props.onFilterChange}
+              onToggle={this.props.onFacetToggle}
+              open={!!openFacets.author}
+              query={query}
+              stats={facets.author}
+            />
+          </>
         )}
       </>
     );
   }
 }
 
-export default withAppStateContext(Sidebar);
+export const Sidebar = withAppStateContext(SidebarClass);
index 9763efc81bf6f2ee2319cae8c3c4304ac6e8f501..14094df81047213aeff67f09df0c97c19428c9ca 100644 (file)
  */
 /* eslint-disable react/no-unused-prop-types */
 
+import { FacetBox, FacetItem } from 'design-system';
 import { omit, 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 ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import ListStyleFacetFooter from '../../../components/facet/ListStyleFacetFooter';
-import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
 import {
   getStandards,
@@ -41,6 +35,10 @@ import { Facet } from '../../../types/issues';
 import { SecurityStandard, Standards } from '../../../types/security';
 import { Dict } from '../../../types/types';
 import { Query, STANDARDS, formatFacetStat } from '../utils';
+import { FacetItemsList } from './FacetItemsList';
+import { ListStyleFacet } from './ListStyleFacet';
+import { ListStyleFacetFooter } from './ListStyleFacetFooter';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface Props {
   cwe: string[];
@@ -76,12 +74,15 @@ type StatsProp =
   | 'owaspTop10Stats'
   | 'cweStats'
   | 'sonarsourceSecurityStats';
+
 type ValuesProp = 'owaspTop10-2021' | 'owaspTop10' | 'sonarsourceSecurity' | 'cwe';
 
 const INITIAL_FACET_COUNT = 15;
-export default class StandardFacet extends React.PureComponent<Props, State> {
+
+export class StandardFacet extends React.PureComponent<Props, State> {
   mounted = false;
   property = STANDARDS;
+
   state: State = {
     showFullSonarSourceList: false,
     standards: {
@@ -127,9 +128,9 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
         owaspTop10,
         cwe,
         sonarsourceSecurity,
-        'pciDss-3.2': pciDss3_2,
-        'pciDss-4.0': pciDss4_0,
-        'owaspAsvs-4.0': owaspAsvs4_0,
+        'pciDss-3.2': pciDss32,
+        'pciDss-4.0': pciDss40,
+        'owaspAsvs-4.0': owaspAsvs40,
       }: Standards) => {
         if (this.mounted) {
           this.setState({
@@ -138,9 +139,9 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
               owaspTop10,
               cwe,
               sonarsourceSecurity,
-              'pciDss-3.2': pciDss3_2,
-              'pciDss-4.0': pciDss4_0,
-              'owaspAsvs-4.0': owaspAsvs4_0,
+              'pciDss-3.2': pciDss32,
+              'pciDss-4.0': pciDss40,
+              'owaspAsvs-4.0': owaspAsvs40,
             },
           });
         }
@@ -196,10 +197,12 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
 
   handleItemClick = (prop: ValuesProp, itemValue: string, multiple: boolean) => {
     const items = this.props[prop];
+
     if (multiple) {
       const newValue = sortBy(
         items.includes(itemValue) ? without(items, itemValue) : [...items, itemValue]
       );
+
       this.props.onChange({ [prop]: newValue });
     } else {
       this.props.onChange({
@@ -230,6 +233,7 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
 
   loadCWESearchResultCount = (categories: string[]) => {
     const { loadSearchResultCount } = this.props;
+
     return loadSearchResultCount
       ? loadSearchResultCount('cwe', { cwe: categories })
       : Promise.resolve({});
@@ -243,27 +247,21 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
   ) => {
     const stats = this.props[statsProp];
     const values = this.props[valuesProp];
+
     if (!stats) {
       return null;
     }
+
     const categories = sortBy(Object.keys(stats), (key) => -stats[key]);
-    return this.renderFacetItemsList(
-      stats,
-      values,
-      categories,
-      valuesProp,
-      renderName,
-      renderName,
-      onClick
-    );
+
+    return this.renderFacetItemsList(stats, values, categories, renderName, renderName, onClick);
   };
 
   // eslint-disable-next-line max-params
   renderFacetItemsList = (
-    stats: any,
+    stats: Dict<number | undefined>,
     values: string[],
     categories: string[],
-    listKey: ValuesProp,
     renderName: (standards: Standards, category: string) => React.ReactNode,
     renderTooltip: (standards: Standards, category: string) => string,
     onClick: (x: string, multiple?: boolean) => void
@@ -280,27 +278,30 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
       return stats ? stats[category] : undefined;
     };
 
-    return (
-      <FacetItemsList labelledby={this.getFacetHeaderId(listKey)}>
-        {categories.map((category) => (
-          <FacetItem
-            active={values.includes(category)}
-            key={category}
-            name={renderName(this.state.standards, category)}
-            onClick={onClick}
-            stat={formatFacetStat(getStat(category))}
-            tooltip={renderTooltip(this.state.standards, category)}
-            value={category}
-          />
-        ))}
-      </FacetItemsList>
-    );
+    return categories.map((category) => (
+      <FacetItem
+        active={values.includes(category)}
+        className="it__search-navigator-facet"
+        key={category}
+        name={renderName(this.state.standards, category)}
+        onClick={onClick}
+        stat={formatFacetStat(getStat(category)) ?? 0}
+        tooltip={renderTooltip(this.state.standards, category)}
+        value={category}
+      />
+    ));
   };
 
   renderHint = (statsProp: StatsProp, valuesProp: ValuesProp) => {
-    const stats = this.props[statsProp] || {};
-    const values = this.props[valuesProp];
-    return <MultipleSelectionHint options={Object.keys(stats).length} values={values.length} />;
+    const nbSelectableItems = Object.keys(this.props[statsProp] ?? {}).length;
+    const nbSelectedItems = this.props[valuesProp].length;
+
+    return (
+      <MultipleSelectionHint
+        nbSelectableItems={nbSelectableItems}
+        nbSelectedItems={nbSelectedItems}
+      />
+    );
   };
 
   renderOwaspTop10List() {
@@ -345,44 +346,45 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
       : sortedItems.slice(INITIAL_FACET_COUNT).filter((item) => values.includes(item));
 
     const allItemShown = limitedList.length + selectedBelowLimit.length === sortedItems.length;
+
     return (
       <>
-        <FacetItemsList labelledby={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}>
-          {limitedList.map((item) => (
-            <FacetItem
-              active={values.includes(item)}
-              key={item}
-              name={renderSonarSourceSecurityCategory(this.state.standards, item)}
-              onClick={this.handleSonarSourceSecurityItemClick}
-              stat={formatFacetStat(stats[item])}
-              tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
-              value={item}
-            />
-          ))}
-        </FacetItemsList>
+        {limitedList.map((item) => (
+          <FacetItem
+            active={values.includes(item)}
+            className="it__search-navigator-facet"
+            key={item}
+            name={renderSonarSourceSecurityCategory(this.state.standards, item)}
+            onClick={this.handleSonarSourceSecurityItemClick}
+            stat={formatFacetStat(stats[item]) ?? 0}
+            tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
+            value={item}
+          />
+        ))}
+
         {selectedBelowLimit.length > 0 && (
           <>
             {!allItemShown && <div className="note spacer-bottom text-center">⋯</div>}
-            <FacetItemsList labelledby={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}>
-              {selectedBelowLimit.map((item) => (
-                <FacetItem
-                  active
-                  key={item}
-                  name={renderSonarSourceSecurityCategory(this.state.standards, item)}
-                  onClick={this.handleSonarSourceSecurityItemClick}
-                  stat={formatFacetStat(stats[item])}
-                  tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
-                  value={item}
-                />
-              ))}
-            </FacetItemsList>
+            {selectedBelowLimit.map((item) => (
+              <FacetItem
+                active
+                className="it__search-navigator-facet"
+                key={item}
+                name={renderSonarSourceSecurityCategory(this.state.standards, item)}
+                onClick={this.handleSonarSourceSecurityItemClick}
+                stat={formatFacetStat(stats[item]) ?? 0}
+                tooltip={renderSonarSourceSecurityCategory(this.state.standards, item)}
+                value={item}
+              />
+            ))}
           </>
         )}
+
         {!allItemShown && (
           <ListStyleFacetFooter
-            showMoreAriaLabel={translate('issues.facet.sonarsource.show_more')}
-            count={limitedList.length + selectedBelowLimit.length}
+            nbShown={limitedList.length + selectedBelowLimit.length}
             showMore={() => this.setState({ showFullSonarSourceList: true })}
+            showMoreAriaLabel={translate('issues.facet.sonarsource.show_more')}
             total={sortedItems.length}
           />
         )}
@@ -419,67 +421,75 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
       sonarsourceSecurity,
       sonarsourceSecurityOpen,
     } = this.props;
+
+    const standards = [
+      {
+        count: sonarsourceSecurity.length,
+        loading: fetchingSonarSourceSecurity,
+        name: 'sonarsourceSecurity',
+        onClick: this.handleSonarSourceSecurityHeaderClick,
+        open: sonarsourceSecurityOpen,
+        panel: (
+          <>
+            {this.renderSonarSourceSecurityList()}
+            {this.renderSonarSourceSecurityHint()}
+          </>
+        ),
+        property: SecurityStandard.SONARSOURCE,
+      },
+      {
+        count: owaspTop102021.length,
+        loading: fetchingOwaspTop102021,
+        name: 'owaspTop10_2021',
+        onClick: this.handleOwaspTop102021HeaderClick,
+        open: owaspTop102021Open,
+        panel: (
+          <>
+            {this.renderOwaspTop102021List()}
+            {this.renderOwaspTop102021Hint()}
+          </>
+        ),
+        property: SecurityStandard.OWASP_TOP10_2021,
+      },
+      {
+        count: owaspTop10.length,
+        loading: fetchingOwaspTop10,
+        name: 'owaspTop10',
+        onClick: this.handleOwaspTop10HeaderClick,
+        open: owaspTop10Open,
+        panel: (
+          <>
+            {this.renderOwaspTop10List()}
+            {this.renderOwaspTop10Hint()}
+          </>
+        ),
+        property: SecurityStandard.OWASP_TOP10,
+      },
+    ];
+
     return (
       <>
-        <FacetBox className="is-inner" property={SecurityStandard.SONARSOURCE}>
-          <FacetHeader
-            fetching={fetchingSonarSourceSecurity}
-            id={this.getFacetHeaderId(SecurityStandard.SONARSOURCE)}
-            name={translate('issues.facet.sonarsourceSecurity')}
-            onClick={this.handleSonarSourceSecurityHeaderClick}
-            open={sonarsourceSecurityOpen}
-            values={sonarsourceSecurity.map((item) =>
-              renderSonarSourceSecurityCategory(this.state.standards, item)
-            )}
-          />
-          {sonarsourceSecurityOpen && (
-            <>
-              {this.renderSonarSourceSecurityList()}
-              {this.renderSonarSourceSecurityHint()}
-            </>
-          )}
-        </FacetBox>
-        <FacetBox className="is-inner" property={SecurityStandard.OWASP_TOP10_2021}>
-          <FacetHeader
-            fetching={fetchingOwaspTop102021}
-            id={this.getFacetHeaderId(SecurityStandard.OWASP_TOP10_2021)}
-            name={translate('issues.facet.owaspTop10_2021')}
-            onClick={this.handleOwaspTop102021HeaderClick}
-            open={owaspTop102021Open}
-            values={owaspTop102021.map((item) =>
-              renderOwaspTop102021Category(this.state.standards, item)
-            )}
-          />
-          {owaspTop102021Open && (
-            <>
-              {this.renderOwaspTop102021List()}
-              {this.renderOwaspTop102021Hint()}
-            </>
-          )}
-        </FacetBox>
-        <FacetBox className="is-inner" property={SecurityStandard.OWASP_TOP10}>
-          <FacetHeader
-            fetching={fetchingOwaspTop10}
-            id={this.getFacetHeaderId(SecurityStandard.OWASP_TOP10)}
-            name={translate('issues.facet.owaspTop10')}
-            onClick={this.handleOwaspTop10HeaderClick}
-            open={owaspTop10Open}
-            values={owaspTop10.map((item) => renderOwaspTop10Category(this.state.standards, item))}
-          />
-          {owaspTop10Open && (
-            <>
-              {this.renderOwaspTop10List()}
-              {this.renderOwaspTop10Hint()}
-            </>
-          )}
-        </FacetBox>
+        {standards.map(({ name, open, panel, property, ...standard }) => (
+          <FacetBox
+            className="it__search-navigator-facet-box it__search-navigator-facet-header"
+            data-property={property}
+            id={this.getFacetHeaderId(property)}
+            inner={true}
+            key={property}
+            name={translate(`issues.facet.${name}`)}
+            open={open}
+            {...standard}
+          >
+            <FacetItemsList labelledby={this.getFacetHeaderId(property)}>{panel}</FacetItemsList>
+          </FacetBox>
+        ))}
+
         <ListStyleFacet<string>
-          className="is-inner"
           facetHeader={translate('issues.facet.cwe')}
           fetching={fetchingCwe}
           getFacetItemText={(item) => renderCWECategory(this.state.standards, item)}
-          getSearchResultKey={(item) => item}
           getSearchResultText={(item) => renderCWECategory(this.state.standards, item)}
+          inner={true}
           loadSearchResultCount={this.loadCWESearchResultCount}
           onChange={this.props.onChange}
           onSearch={this.handleCWESearch}
@@ -502,18 +512,23 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
   render() {
     const { open } = this.props;
 
-    return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          id={this.getFacetHeaderId(this.property)}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={this.getValues()}
-        />
+    const count = this.getValues().length;
 
-        {open && this.renderSubFacets()}
+    return (
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={count}
+        countLabel={translateWithParameters('x_selected', count)}
+        data-property={this.property}
+        hasEmbeddedFacets={true}
+        id={this.getFacetHeaderId(this.property)}
+        name={translate('issues.facet', this.property)}
+        onClear={this.handleClear}
+        onClick={this.handleHeaderClick}
+        open={open}
+      >
+        {this.renderSubFacets()}
       </FacetBox>
     );
   }
index a5391de37405433a52ec29b6db2177e50e16b38d..0f3a1e7ddcd3d8040b5741c92afdbc0c5874bcea 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import {
+  FacetBox,
+  FacetItem,
+  StatusConfirmedIcon,
+  StatusOpenIcon,
+  StatusReopenedIcon,
+  StatusResolvedIcon,
+} from 'design-system';
 import { orderBy, 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 StatusHelper from '../../../components/shared/StatusHelper';
-import { translate } from '../../../helpers/l10n';
+import { STATUSES } from '../../../helpers/constants';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query, formatFacetStat } from '../utils';
+import { FacetItemsColumns } from './FacetItemsColumns';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface Props {
   fetching: boolean;
@@ -38,19 +44,19 @@ interface Props {
   statuses: string[];
 }
 
-const STATUSES = ['OPEN', 'CONFIRMED', 'REOPENED', 'RESOLVED', 'CLOSED'];
-
-export default class StatusFacet extends React.PureComponent<Props> {
+export class StatusFacet extends React.PureComponent<Props> {
   property = 'statuses';
 
   static defaultProps = { open: true };
 
   handleItemClick = (itemValue: string, multiple: boolean) => {
     const { statuses } = this.props;
+
     if (multiple) {
       const newValue = orderBy(
         statuses.includes(itemValue) ? without(statuses, itemValue) : [...statuses, itemValue]
       );
+
       this.props.onChange({ [this.property]: newValue });
     } else {
       this.props.onChange({
@@ -69,6 +75,7 @@ export default class StatusFacet extends React.PureComponent<Props> {
 
   getStat(status: string) {
     const { stats } = this.props;
+
     return stats ? stats[status] : undefined;
   }
 
@@ -79,11 +86,20 @@ export default class StatusFacet extends React.PureComponent<Props> {
     return (
       <FacetItem
         active={active}
-        halfWidth
+        className="it__search-navigator-facet"
+        icon={
+          {
+            CLOSED: <StatusResolvedIcon />,
+            CONFIRMED: <StatusConfirmedIcon />,
+            OPEN: <StatusOpenIcon />,
+            REOPENED: <StatusReopenedIcon />,
+            RESOLVED: <StatusResolvedIcon />,
+          }[status]
+        }
         key={status}
-        name={<StatusHelper resolution={undefined} status={status} />}
+        name={translate('issue.status', status)}
         onClick={this.handleItemClick}
-        stat={formatFacetStat(stat)}
+        stat={formatFacetStat(stat) ?? 0}
         tooltip={translate('issue.status', status)}
         value={status}
       />
@@ -91,28 +107,32 @@ export default class StatusFacet extends React.PureComponent<Props> {
   };
 
   render() {
-    const { fetching, open, statuses, stats = {} } = this.props;
-    const values = statuses.map((status) => translate('issue.status', status));
+    const { fetching, open, statuses } = this.props;
+
+    const nbSelectableItems = STATUSES.filter(this.getStat.bind(this)).length;
+    const nbSelectedItems = statuses.length;
     const headerId = `facet_${this.property}`;
 
     return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          fetching={fetching}
-          id={headerId}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={values}
-        />
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={nbSelectedItems}
+        countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+        data-property={this.property}
+        id={headerId}
+        loading={fetching}
+        name={translate('issues.facet', this.property)}
+        onClear={this.handleClear}
+        onClick={this.handleHeaderClick}
+        open={open}
+      >
+        <FacetItemsColumns>{STATUSES.map(this.renderItem)}</FacetItemsColumns>
 
-        {open && (
-          <>
-            <FacetItemsList labelledby={headerId}>{STATUSES.map(this.renderItem)}</FacetItemsList>
-            <MultipleSelectionHint options={Object.keys(stats).length} values={statuses.length} />
-          </>
-        )}
+        <MultipleSelectionHint
+          nbSelectableItems={nbSelectableItems}
+          nbSelectedItems={nbSelectedItems}
+        />
       </FacetBox>
     );
   }
index 3744f609b40961c67a5efbc1234b9b1103d7189f..79bce8a6d4db61e6d92b1b5fc40256f96920798b 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
 import { omit } from 'lodash';
 import * as React from 'react';
 import { searchIssueTags } from '../../../api/issues';
-import { colors } from '../../../app/theme';
-import ListStyleFacet from '../../../components/facet/ListStyleFacet';
-import TagsIcon from '../../../components/icons/TagsIcon';
 import { translate } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
+import { ComponentQualifier } from '../../../types/component';
 import { Facet } from '../../../types/issues';
 import { Component, Dict } from '../../../types/types';
 import { Query } from '../utils';
+import { ListStyleFacet } from './ListStyleFacet';
 
 interface Props {
   component: Component | undefined;
@@ -44,11 +44,20 @@ interface Props {
 
 const SEARCH_SIZE = 100;
 
-export default class TagFacet extends React.PureComponent<Props> {
+export class TagFacet extends React.PureComponent<Props> {
   handleSearch = (query: string) => {
     const { component, branch } = this.props;
+
     const project =
-      component && ['TRK', 'VW', 'APP'].includes(component.qualifier) ? component.key : undefined;
+      component &&
+      [
+        ComponentQualifier.Project,
+        ComponentQualifier.Portfolio,
+        ComponentQualifier.Application,
+      ].includes(component.qualifier as ComponentQualifier)
+        ? component.key
+        : undefined;
+
     return searchIssueTags({
       project,
       branch,
@@ -65,30 +74,12 @@ export default class TagFacet extends React.PureComponent<Props> {
     return this.props.loadSearchResultCount('tags', { tags });
   };
 
-  renderTag = (tag: string) => {
-    return (
-      <>
-        <TagsIcon className="little-spacer-right" fill={colors.gray60} />
-        {tag}
-      </>
-    );
-  };
-
-  renderSearchResult = (tag: string, term: string) => (
-    <>
-      <TagsIcon className="little-spacer-right" fill={colors.gray60} />
-      {highlightTerm(tag, term)}
-    </>
-  );
-
   render() {
     return (
       <ListStyleFacet<string>
         facetHeader={translate('issues.facet.tags')}
         fetching={this.props.fetching}
         getFacetItemText={this.getTagName}
-        getSearchResultKey={(tag) => tag}
-        getSearchResultText={(tag) => tag}
         loadSearchResultCount={this.loadSearchResultCount}
         onChange={this.props.onChange}
         onSearch={this.handleSearch}
@@ -96,8 +87,7 @@ export default class TagFacet extends React.PureComponent<Props> {
         open={this.props.open}
         property="tags"
         query={omit(this.props.query, 'tags')}
-        renderFacetItem={this.renderTag}
-        renderSearchResult={this.renderSearchResult}
+        renderSearchResult={highlightTerm}
         searchPlaceholder={translate('search.search_for_tags')}
         stats={this.props.stats}
         values={this.props.tags}
index 27984448995ca63950bb01321640fa5120e9f506..36b0a91c484e024063e4f199fbd81be0e9881bf6 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { BugIcon, CodeSmellIcon, FacetBox, FacetItem, VulnerabilityIcon } from 'design-system';
 import { orderBy, 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 IssueTypeIcon from '../../../components/icons/IssueTypeIcon';
 import { ISSUE_TYPES } from '../../../helpers/constants';
-import { translate } from '../../../helpers/l10n';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query, formatFacetStat } from '../utils';
+import { FacetItemsList } from './FacetItemsList';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface Props {
   fetching: boolean;
@@ -39,7 +37,9 @@ interface Props {
   types: string[];
 }
 
-export default class TypeFacet extends React.PureComponent<Props> {
+const AVAILABLE_TYPES = ISSUE_TYPES.filter((t) => t !== 'SECURITY_HOTSPOT');
+
+export class TypeFacet extends React.PureComponent<Props> {
   property = 'types';
 
   static defaultProps = {
@@ -70,6 +70,7 @@ export default class TypeFacet extends React.PureComponent<Props> {
 
   getStat(type: string) {
     const { stats } = this.props;
+
     return stats ? stats[type] : undefined;
   }
 
@@ -84,45 +85,50 @@ export default class TypeFacet extends React.PureComponent<Props> {
     return (
       <FacetItem
         active={active}
-        key={type}
-        name={
-          <span className="display-flex-center">
-            <IssueTypeIcon className="little-spacer-right" query={type} />{' '}
-            {translate('issue.type', type)}
-          </span>
+        className="it__search-navigator-facet"
+        icon={
+          { BUG: <BugIcon />, CODE_SMELL: <CodeSmellIcon />, VULNERABILITY: <VulnerabilityIcon /> }[
+            type
+          ]
         }
+        key={type}
+        name={translate('issue.type', type)}
         onClick={this.handleItemClick}
-        stat={formatFacetStat(stat)}
+        stat={formatFacetStat(stat) ?? 0}
         value={type}
       />
     );
   };
 
   render() {
-    const { fetching, open, types, stats = {} } = this.props;
-    const values = types.map((type) => translate('issue.type', type));
+    const { fetching, open, types } = this.props;
+
+    const nbSelectableItems = AVAILABLE_TYPES.filter(this.getStat.bind(this)).length;
+    const nbSelectedItems = types.length;
     const typeFacetHeaderId = `facet_${this.property}`;
 
     return (
-      <FacetBox property={this.property}>
-        <FacetHeader
-          fetching={fetching}
-          id={typeFacetHeaderId}
-          name={translate('issues.facet', this.property)}
-          onClear={this.handleClear}
-          onClick={this.handleHeaderClick}
-          open={open}
-          values={values}
-        />
+      <FacetBox
+        className="it__search-navigator-facet-box it__search-navigator-facet-header"
+        clearIconLabel={translate('clear')}
+        count={nbSelectedItems}
+        countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+        data-property={this.property}
+        id={typeFacetHeaderId}
+        loading={fetching}
+        name={translate('issues.facet', this.property)}
+        onClear={this.handleClear}
+        onClick={this.handleHeaderClick}
+        open={open}
+      >
+        <FacetItemsList labelledby={typeFacetHeaderId}>
+          {AVAILABLE_TYPES.map(this.renderItem)}
+        </FacetItemsList>
 
-        {open && (
-          <>
-            <FacetItemsList labelledby={typeFacetHeaderId}>
-              {ISSUE_TYPES.filter((t) => t !== 'SECURITY_HOTSPOT').map(this.renderItem)}
-            </FacetItemsList>
-            <MultipleSelectionHint options={Object.keys(stats).length} values={types.length} />
-          </>
-        )}
+        <MultipleSelectionHint
+          nbSelectableItems={nbSelectableItems}
+          nbSelectedItems={nbSelectedItems}
+        />
       </FacetBox>
     );
   }
index 954f0bb5757940ffc99a0d2a82f1d801969f20cf..b7a2a50d71691e6f7b506b58fce915da9ea5c242 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { FacetBox, FacetItem } from 'design-system';
 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 { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query, formatFacetStat } from '../utils';
+import { FacetItemsList } from './FacetItemsList';
+import { MultipleSelectionHint } from './MultipleSelectionHint';
 
 interface VariantFacetProps {
   fetching: boolean;
@@ -39,7 +38,7 @@ interface VariantFacetProps {
 
 const FACET_NAME = 'codeVariants';
 
-export default function VariantFacet(props: VariantFacetProps) {
+export function VariantFacet(props: VariantFacetProps) {
   const { open, fetching, stats = {}, values, onToggle, onChange } = props;
 
   const handleClear = React.useCallback(() => {
@@ -58,6 +57,7 @@ export default function VariantFacet(props: VariantFacetProps) {
         const newValues = orderBy(
           values.includes(value) ? without(values, value) : [...values, value]
         );
+
         onChange({ [FACET_NAME]: newValues });
       } else {
         onChange({
@@ -65,46 +65,55 @@ export default function VariantFacet(props: VariantFacetProps) {
         });
       }
     },
+
     [values, onChange]
   );
 
   const id = `facet_${FACET_NAME}`;
 
+  const nbSelectableItems = Object.keys(stats).length;
+  const nbSelectedItems = values.length;
+
   return (
-    <FacetBox property={FACET_NAME}>
-      <FacetHeader
-        fetching={fetching}
-        name={translate('issues.facet', FACET_NAME)}
-        id={id}
-        onClear={handleClear}
-        onClick={handleHeaderClick}
-        open={open}
-        values={values}
+    <FacetBox
+      className="it__search-navigator-facet-box it__search-navigator-facet-header"
+      clearIconLabel={translate('clear')}
+      count={nbSelectedItems}
+      countLabel={translateWithParameters('x_selected', nbSelectedItems)}
+      data-property={FACET_NAME}
+      id={id}
+      loading={fetching}
+      name={translate('issues.facet', FACET_NAME)}
+      onClear={handleClear}
+      onClick={handleHeaderClick}
+      open={open}
+    >
+      <FacetItemsList labelledby={id}>
+        {nbSelectableItems === 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)}
+            className="it__search-navigator-facet"
+            key={codeVariant}
+            name={codeVariant}
+            onClick={handleItemClick}
+            stat={formatFacetStat(stats[codeVariant])}
+            value={codeVariant}
+          />
+        ))}
+      </FacetItemsList>
+
+      <MultipleSelectionHint
+        nbSelectableItems={nbSelectableItems}
+        nbSelectedItems={nbSelectedItems}
       />
-      {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>
   );
 }
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx
new file mode 100644 (file)
index 0000000..7d701f6
--- /dev/null
@@ -0,0 +1,219 @@
+/*
+ * 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 { shallow, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+import { ListStyleFacet, Props } from '../ListStyleFacet';
+
+it('should render', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should select items', () => {
+  const onChange = jest.fn();
+  const wrapper = shallowRender({ onChange });
+  const instance = wrapper.instance() as ListStyleFacet<string>;
+
+  // select one item
+  instance.handleItemClick('b', false);
+  expect(onChange).toHaveBeenLastCalledWith({ foo: ['b'] });
+  wrapper.setProps({ values: ['b'] });
+
+  // select another item
+  instance.handleItemClick('a', false);
+  expect(onChange).toHaveBeenLastCalledWith({ foo: ['a'] });
+  wrapper.setProps({ values: ['a'] });
+
+  // unselect item
+  instance.handleItemClick('a', false);
+  expect(onChange).toHaveBeenLastCalledWith({ foo: [] });
+  wrapper.setProps({ values: [] });
+
+  // select multiple items
+  wrapper.setProps({ values: ['b'] });
+  instance.handleItemClick('c', true);
+  expect(onChange).toHaveBeenLastCalledWith({ foo: ['b', 'c'] });
+  wrapper.setProps({ values: ['b', 'c'] });
+
+  // unselect item
+  instance.handleItemClick('c', true);
+  expect(onChange).toHaveBeenLastCalledWith({ foo: ['b'] });
+});
+
+it('should toggle', () => {
+  const onToggle = jest.fn();
+  const wrapper = shallowRender({ onToggle });
+  wrapper.find('FacetBox').prop<Function>('onClick')();
+  expect(onToggle).toHaveBeenCalled();
+});
+
+it('should clear', () => {
+  const onChange = jest.fn();
+  const wrapper = shallowRender({ onChange, values: ['a'] });
+  wrapper.find('FacetBox').prop<Function>('onClear')();
+  expect(onChange).toHaveBeenCalledWith({ foo: [] });
+});
+
+it('should search', async () => {
+  const onSearch = jest.fn().mockResolvedValue({
+    results: ['d', 'e'],
+    paging: { pageIndex: 1, pageSize: 2, total: 3 },
+  });
+
+  const loadSearchResultCount = jest.fn().mockResolvedValue({ d: 7, e: 3 });
+  const wrapper = shallowRender({ loadSearchResultCount, onSearch });
+
+  // search
+  wrapper.find('InputSearch').prop<Function>('onChange')('query');
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+  expect(onSearch).toHaveBeenLastCalledWith('query');
+  expect(loadSearchResultCount).toHaveBeenLastCalledWith(['d', 'e']);
+
+  // load more results
+  onSearch.mockResolvedValue({
+    results: ['f'],
+    paging: { pageIndex: 2, pageSize: 2, total: 3 },
+  });
+
+  loadSearchResultCount.mockResolvedValue({ f: 5 });
+  wrapper.find('ListFooter').prop<Function>('loadMore')();
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+  expect(onSearch).toHaveBeenLastCalledWith('query', 2);
+
+  // clear search
+  onSearch.mockClear();
+  loadSearchResultCount.mockClear();
+  wrapper.find('InputSearch').prop<Function>('onChange')('');
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+  expect(onSearch).not.toHaveBeenCalled();
+  expect(loadSearchResultCount).not.toHaveBeenCalled();
+
+  // search for no results
+  onSearch.mockResolvedValue({ results: [], paging: { pageIndex: 1, pageSize: 2, total: 0 } });
+  wrapper.find('InputSearch').prop<Function>('onChange')('blabla');
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot();
+  expect(onSearch).toHaveBeenLastCalledWith('blabla');
+  expect(loadSearchResultCount).not.toHaveBeenCalled();
+
+  // search fails
+  onSearch.mockRejectedValue(undefined);
+  wrapper.find('InputSearch').prop<Function>('onChange')('blabla');
+  await waitAndUpdate(wrapper);
+  expect(wrapper).toMatchSnapshot(); // should render previous results
+  expect(onSearch).toHaveBeenLastCalledWith('blabla');
+  expect(loadSearchResultCount).not.toHaveBeenCalled();
+});
+
+it('should limit the number of items', () => {
+  const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 5 });
+  expect(wrapper.find('FacetItem').length).toBe(2);
+
+  wrapper.find('ListStyleFacetFooter').prop<Function>('showMore')();
+  wrapper.update();
+  expect(wrapper.find('FacetItem').length).toBe(3);
+
+  wrapper.find('ListStyleFacetFooter').prop<Function>('showLess')();
+  wrapper.update();
+  expect(wrapper.find('FacetItem').length).toBe(2);
+});
+
+it('should show warning that there might be more results', () => {
+  const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 3 });
+  wrapper.find('ListStyleFacetFooter').prop<Function>('showMore')();
+  wrapper.update();
+  expect(wrapper.find('FlagMessage').exists()).toBe(true);
+});
+
+// eslint-disable-next-line jest/expect-expect
+it('should reset state when closes', () => {
+  const wrapper = shallowRender();
+
+  wrapper.setState({
+    query: 'foobar',
+    searchResults: ['foo', 'bar'],
+    searching: true,
+    showFullList: true,
+  });
+
+  wrapper.setProps({ open: false });
+  checkInitialState(wrapper);
+});
+
+// eslint-disable-next-line jest/expect-expect
+it('should reset search when query changes', () => {
+  const wrapper = shallowRender({ query: { a: ['foo'] } });
+  wrapper.setState({ query: 'foo', searchResults: ['foo'], searchResultsCounts: { foo: 3 } });
+  wrapper.setProps({ query: { a: ['foo'], b: ['bar'] } });
+  checkInitialState(wrapper);
+});
+
+it('should collapse list when new stats have few results', () => {
+  const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 3 });
+  wrapper.setState({ showFullList: true });
+
+  wrapper.setProps({ stats: { d: 1 } });
+  expect(wrapper.state('showFullList')).toBe(false);
+});
+
+it('should display all selected items', () => {
+  const wrapper = shallowRender({
+    maxInitialItems: 2,
+    stats: { a: 10, b: 5, c: 3 },
+    values: ['a', 'b', 'c'],
+  });
+
+  expect(wrapper).toMatchSnapshot();
+});
+
+it('should be disabled', () => {
+  const wrapper = shallowRender({ disabled: true });
+  expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Props<string>> = {}) {
+  return shallow(
+    <ListStyleFacet
+      facetHeader="facet header"
+      fetching={false}
+      onChange={jest.fn()}
+      onSearch={jest.fn()}
+      onToggle={jest.fn()}
+      open={true}
+      property="foo"
+      searchPlaceholder="search for foo..."
+      stats={{ a: 10, b: 8, c: 1 }}
+      values={[]}
+      {...props}
+    />
+  );
+}
+
+function checkInitialState(wrapper: ShallowWrapper) {
+  expect(wrapper.state('query')).toBe('');
+  expect(wrapper.state('searchResults')).toBeUndefined();
+  expect(wrapper.state('searching')).toBe(false);
+  expect(wrapper.state('searchResultsCounts')).toEqual({});
+  expect(wrapper.state('showFullList')).toBe(false);
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.tsx
new file mode 100644 (file)
index 0000000..89deb00
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * 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 { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { ListStyleFacetFooter, Props } from '../ListStyleFacetFooter';
+
+it('should render "show more", not "show less"', async () => {
+  const showMore = jest.fn();
+
+  render({
+    nbShown: 7,
+    showLessAriaLabel: 'show less',
+    showMore,
+    showMoreAriaLabel: 'show more',
+    total: 42,
+  });
+
+  expect(screen.getByText('x_show.7')).toBeInTheDocument();
+  expect(screen.getByText('show_more')).toBeInTheDocument();
+  expect(screen.getByLabelText('show more')).toBeInTheDocument();
+  expect(screen.queryByText('show_less')).not.toBeInTheDocument();
+  expect(screen.queryByLabelText('show less')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByLabelText('show more'));
+
+  expect(showMore).toHaveBeenCalled();
+});
+
+it('should render neither "show more" nor "show less"', () => {
+  render({ nbShown: 42, total: 42 });
+
+  expect(screen.getByText('x_show.42')).toBeInTheDocument();
+  expect(screen.queryByText('show_more')).not.toBeInTheDocument();
+  expect(screen.queryByText('show_less')).not.toBeInTheDocument();
+});
+
+it('should render "show less", not "show more"', async () => {
+  const showLess = jest.fn();
+
+  render({
+    nbShown: 42,
+    showLess,
+    showLessAriaLabel: 'show less',
+    showMoreAriaLabel: 'show more',
+    total: 42,
+  });
+
+  expect(screen.getByText('x_show.42')).toBeInTheDocument();
+  expect(screen.queryByText('show_more')).not.toBeInTheDocument();
+  expect(screen.queryByLabelText('show more')).not.toBeInTheDocument();
+  expect(screen.getByText('show_less')).toBeInTheDocument();
+  expect(screen.getByLabelText('show less')).toBeInTheDocument();
+
+  await userEvent.click(screen.getByLabelText('show less'));
+
+  expect(showLess).toHaveBeenCalled();
+});
+
+function render(props: Partial<Props> = {}) {
+  return renderComponent(
+    <ListStyleFacetFooter nbShown={1} showMore={jest.fn()} total={42} {...props} />
+  );
+}
index 06d816f076e6dc4f937db8beb0926d0e84ad316f..863e0be2ecdfefa8f355e0835150c920694683a5 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 { screen } from '@testing-library/react';
 import * as React from 'react';
 import { mockComponent } from '../../../../helpers/mocks/component';
@@ -25,10 +26,11 @@ import { mockAppState } from '../../../../helpers/testMocks';
 import { renderComponent } from '../../../../helpers/testReactTestingUtils';
 import { ComponentQualifier } from '../../../../types/component';
 import { GlobalSettingKeys } from '../../../../types/settings';
-import { Sidebar } from '../Sidebar';
+import { SidebarClass as Sidebar } from '../Sidebar';
 
 it('should render correct facets for Application', () => {
   renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.Application }) });
+
   expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([
     'issues.facet.types',
     'issues.facet.severities',
@@ -42,13 +44,14 @@ it('should render correct facets for Application', () => {
     'issues.facet.tags',
     'issues.facet.projects',
     'issues.facet.assignees',
-    'clear',
+    '',
     'issues.facet.authors',
   ]);
 });
 
 it('should render correct facets for Portfolio', () => {
   renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.Portfolio }) });
+
   expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([
     'issues.facet.types',
     'issues.facet.severities',
@@ -62,13 +65,14 @@ it('should render correct facets for Portfolio', () => {
     'issues.facet.tags',
     'issues.facet.projects',
     'issues.facet.assignees',
-    'clear',
+    '',
     'issues.facet.authors',
   ]);
 });
 
 it('should render correct facets for SubPortfolio', () => {
   renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.SubPortfolio }) });
+
   expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([
     'issues.facet.types',
     'issues.facet.severities',
@@ -82,7 +86,7 @@ it('should render correct facets for SubPortfolio', () => {
     'issues.facet.tags',
     'issues.facet.projects',
     'issues.facet.assignees',
-    'clear',
+    '',
     'issues.facet.authors',
   ]);
 });
@@ -99,6 +103,7 @@ it.each([
     month: 'issues.facet.createdAt.last_month',
     year: 'issues.facet.createdAt.last_year',
   }[name] as string;
+
   expect(screen.getByText(text)).toBeInTheDocument();
 });
 
@@ -116,7 +121,7 @@ function renderSidebar(props: Partial<Sidebar['props']> = {}) {
       myIssues={false}
       onFacetToggle={jest.fn()}
       onFilterChange={jest.fn()}
-      openFacets={{}}
+      openFacets={{ createdAt: true }}
       showVariantsFilter={false}
       query={mockQuery()}
       referencedComponentsById={{}}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
new file mode 100644 (file)
index 0000000..7fb1d3d
--- /dev/null
@@ -0,0 +1,464 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should be disabled 1`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  disabled={true}
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  open={false}
+/>
+`;
+
+exports[`should display all selected items 1`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={3}
+  countLabel="x_selected.3"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value=""
+    />
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={true}
+        className="it__search-navigator-facet"
+        key="a"
+        name="a"
+        onClick={[Function]}
+        stat="10"
+        tooltip="a"
+        value="a"
+      />
+      <FacetItem
+        active={true}
+        className="it__search-navigator-facet"
+        key="b"
+        name="b"
+        onClick={[Function]}
+        stat="5"
+        tooltip="b"
+        value="b"
+      />
+    </FacetItemsList>
+    <div
+      className="note spacer-bottom text-center"
+    >
+      â‹¯
+    </div>
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={true}
+        className="it__search-navigator-facet"
+        key="c"
+        name="c"
+        onClick={[Function]}
+        stat="3"
+        tooltip="c"
+        value="c"
+      />
+    </FacetItemsList>
+    <ListStyleFacetFooter
+      nbShown={3}
+      showMore={[Function]}
+      total={3}
+    />
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={3}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should render 1`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value=""
+    />
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="a"
+        name="a"
+        onClick={[Function]}
+        stat="10"
+        tooltip="a"
+        value="a"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="b"
+        name="b"
+        onClick={[Function]}
+        stat="8"
+        tooltip="b"
+        value="b"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="c"
+        name="c"
+        onClick={[Function]}
+        stat="1"
+        tooltip="c"
+        value="c"
+      />
+    </FacetItemsList>
+    <ListStyleFacetFooter
+      nbShown={3}
+      showMore={[Function]}
+      total={3}
+    />
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should search 1`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value="query"
+    />
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="d"
+        name="d"
+        onClick={[Function]}
+        stat="7"
+        tooltip="d"
+        value="d"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="e"
+        name="e"
+        onClick={[Function]}
+        stat="3"
+        tooltip="e"
+        value="e"
+      />
+    </FacetItemsList>
+    <ListFooter
+      className="sw-mb-2"
+      count={2}
+      loadMore={[Function]}
+      ready={true}
+      total={3}
+      useMIUIButtons={true}
+    />
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should search 2`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value="query"
+    />
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="d"
+        name="d"
+        onClick={[Function]}
+        stat="7"
+        tooltip="d"
+        value="d"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="e"
+        name="e"
+        onClick={[Function]}
+        stat="3"
+        tooltip="e"
+        value="e"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="f"
+        name="f"
+        onClick={[Function]}
+        stat="5"
+        tooltip="f"
+        value="f"
+      />
+    </FacetItemsList>
+    <ListFooter
+      className="sw-mb-2"
+      count={3}
+      loadMore={[Function]}
+      ready={true}
+      total={3}
+      useMIUIButtons={true}
+    />
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should search 3`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value=""
+    />
+    <FacetItemsList
+      labelledby="facet_foo"
+    >
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="a"
+        name="a"
+        onClick={[Function]}
+        stat="10"
+        tooltip="a"
+        value="a"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="b"
+        name="b"
+        onClick={[Function]}
+        stat="8"
+        tooltip="b"
+        value="b"
+      />
+      <FacetItem
+        active={false}
+        className="it__search-navigator-facet"
+        key="c"
+        name="c"
+        onClick={[Function]}
+        stat="1"
+        tooltip="c"
+        value="c"
+      />
+    </FacetItemsList>
+    <ListStyleFacetFooter
+      nbShown={3}
+      showMore={[Function]}
+      total={3}
+    />
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should search 4`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value="blabla"
+    />
+    <div
+      className="note spacer-bottom"
+    >
+      no_results
+    </div>
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
+
+exports[`should search 5`] = `
+<FacetBox
+  className="it__search-navigator-facet-box it__search-navigator-facet-header"
+  clearIconLabel="clear"
+  count={0}
+  countLabel="x_selected.0"
+  id="facet_foo"
+  loading={false}
+  name="facet header"
+  onClear={[Function]}
+  onClick={[Function]}
+  open={true}
+>
+  <span
+    className="it__search-navigator-facet-list"
+  >
+    <InputSearch
+      autoFocus={false}
+      className="it__search-box-input sw-mb-4 sw-w-full"
+      clearIconAriaLabel="clear"
+      onChange={[Function]}
+      placeholder="search for foo..."
+      searchInputAriaLabel="search_verb"
+      size="auto"
+      value="blabla"
+    />
+    <div
+      className="note spacer-bottom"
+    >
+      no_results
+    </div>
+    <MultipleSelectionHint
+      nbSelectableItems={3}
+      nbSelectedItems={0}
+    />
+  </span>
+</FacetBox>
+`;
index 822e84ba537d97cd15f68b6de71b60a1c0ca821b..656690c61593648fd7781d8523bf4cad8d6a7ccf 100644 (file)
   transition: background-color 0.3s ease, border-color 0.3s ease;
 }
 
-.not-all-issue-warning {
-  padding: 16px 16px 0;
-  width: 100%;
-  box-sizing: border-box;
-}
-
 .not-all-issue-warning.open-issue-list {
+  background-color: var(--barBackgroundColor);
+  box-sizing: border-box;
+  display: inline-block;
+  padding: 16px 16px 0;
   position: sticky;
   top: 0;
   z-index: 1000;
-  background-color: var(--barBackgroundColor);
-  display: inline-block;
 }
 
 .concise-issue-box .issue-message-highlight-CODE {
index de8b823ce96f1cfed7e182df6397870cdcaf4b90..332697a4a83900868ee6ad729acf30490e90e36e 100644 (file)
@@ -19,7 +19,7 @@
  */
 import { waitFor } from '@testing-library/react';
 import React from 'react';
-import { byLabelText, byRole } from 'testing-library-selector';
+import { byLabelText, byPlaceholderText, byRole, byTestId } from 'testing-library-selector';
 import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
 import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
 import { mockComponent } from '../../helpers/mocks/component';
@@ -47,46 +47,50 @@ export const ui = {
   issueItem8: byRole('region', { name: 'Issue on page 2' }),
   projectIssueItem6: byRole('button', { name: 'Second issue', exact: false }),
 
-  clearIssueTypeFacet: byRole('button', { name: 'clear_x_filter.issues.facet.types' }),
-  codeSmellIssueTypeFilter: byRole('checkbox', { name: 'issue.type.CODE_SMELL' }),
-  vulnerabilityIssueTypeFilter: byRole('checkbox', { name: 'issue.type.VULNERABILITY' }),
-  clearSeverityFacet: byRole('button', { name: 'clear_x_filter.issues.facet.severities' }),
-  majorSeverityFilter: byRole('checkbox', { name: 'severity.MAJOR' }),
-  scopeFacet: byRole('button', { name: 'issues.facet.scopes' }),
-  clearScopeFacet: byRole('button', { name: 'clear_x_filter.issues.facet.scopes' }),
-  mainScopeFilter: byRole('checkbox', { name: 'issue.scope.MAIN' }),
-  resolutionFacet: byRole('button', { name: 'issues.facet.resolutions' }),
-  clearResolutionFacet: byRole('button', { name: 'clear_x_filter.issues.facet.resolutions' }),
-  fixedResolutionFilter: byRole('checkbox', { name: 'issue.resolution.FIXED' }),
-  statusFacet: byRole('button', { name: 'issues.facet.statuses' }),
+  assigneeFacet: byRole('button', { name: 'issues.facet.assignees' }),
+  authorFacet: byRole('button', { name: 'issues.facet.authors' }),
+  codeVariantsFacet: byRole('button', { name: 'issues.facet.codeVariants' }),
   creationDateFacet: byRole('button', { name: 'issues.facet.createdAt' }),
-  clearCreationDateFacet: byRole('button', { name: 'clear_x_filter.issues.facet.createdAt' }),
-  clearStatusFacet: byRole('button', { name: 'clear_x_filter.issues.facet.statuses' }),
-  openStatusFilter: byRole('checkbox', { name: 'issue.status.OPEN' }),
-  confirmedStatusFilter: byRole('checkbox', { name: 'issue.status.CONFIRMED' }),
   languageFacet: byRole('button', { name: 'issues.facet.languages' }),
+  projectFacet: byRole('button', { name: 'issues.facet.projects' }),
+  resolutionFacet: byRole('button', { name: 'issues.facet.resolutions' }),
   ruleFacet: byRole('button', { name: 'issues.facet.rules' }),
-  clearRuleFacet: byRole('button', { name: 'clear_x_filter.issues.facet.rules' }),
+  scopeFacet: byRole('button', { name: 'issues.facet.scopes' }),
+  statusFacet: byRole('button', { name: 'issues.facet.statuses' }),
   tagFacet: byRole('button', { name: 'issues.facet.tags' }),
-  clearTagFacet: byRole('button', { name: 'clear_x_filter.issues.facet.tags' }),
-  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:' }),
+  clearAssigneeFacet: byTestId('clear-issues.facet.assignees'),
+  clearAuthorFacet: byTestId('clear-issues.facet.authors'),
+  clearCodeVariantsFacet: byTestId('clear-issues.facet.codeVariants'),
+  clearCreationDateFacet: byTestId('clear-issues.facet.createdAt'),
+  clearIssueTypeFacet: byTestId('clear-issues.facet.types'),
+  clearProjectFacet: byTestId('clear-issues.facet.projects'),
+  clearResolutionFacet: byTestId('clear-issues.facet.resolutions'),
+  clearRuleFacet: byTestId('clear-issues.facet.rules'),
+  clearScopeFacet: byTestId('clear-issues.facet.scopes'),
+  clearSeverityFacet: byTestId('clear-issues.facet.severities'),
+  clearStatusFacet: byTestId('clear-issues.facet.statuses'),
+  clearTagFacet: byTestId('clear-issues.facet.tags'),
+
+  codeSmellIssueTypeFilter: byRole('checkbox', { name: 'issue.type.CODE_SMELL' }),
+  confirmedStatusFilter: byRole('checkbox', { name: 'issue.status.CONFIRMED' }),
+  fixedResolutionFilter: byRole('checkbox', { name: 'issue.resolution.FIXED' }),
+  mainScopeFilter: byRole('checkbox', { name: 'issue.scope.MAIN' }),
+  majorSeverityFilter: byRole('checkbox', { name: 'severity.MAJOR' }),
+  openStatusFilter: byRole('checkbox', { name: 'issue.status.OPEN' }),
+  vulnerabilityIssueTypeFilter: byRole('checkbox', { name: 'issue.type.VULNERABILITY' }),
 
   clearAllFilters: byRole('button', { name: 'clear_all_filters' }),
 
-  ruleFacetList: byRole('list', { name: 'issues.facet.rules' }),
-  languageFacetList: byRole('list', { name: 'issues.facet.languages' }),
-  ruleFacetSearch: byRole('searchbox', { name: 'search.search_for_rules' }),
+  dateInputMonthSelect: byTestId('month-select'),
+  dateInputYearSelect: byTestId('year-select'),
+
+  authorFacetSearch: byPlaceholderText('search.search_for_authors'),
   inNewCodeFilter: byRole('checkbox', { name: 'issues.new_code' }),
+  languageFacetList: byRole('list', { name: 'issues.facet.languages' }),
+  ruleFacetList: byRole('list', { name: 'issues.facet.rules' }),
+  ruleFacetSearch: byPlaceholderText('search.search_for_rules'),
+  tagFacetSearch: byPlaceholderText('search.search_for_tags'),
 };
 
 export async function waitOnDataLoaded() {
index 361c91db01b8587901b7f7d9cd671c8fcc932fef..cc646753c0e8603f5814bcad86270cffd18750b4 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 classNames from 'classnames';
 import * as React from 'react';
 
@@ -35,7 +36,6 @@ export interface Props {
 export default class FacetItem extends React.PureComponent<Props> {
   static defaultProps = {
     halfWidth: false,
-    loading: false,
   };
 
   handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -53,6 +53,7 @@ export default class FacetItem extends React.PureComponent<Props> {
 
   render() {
     const { name, halfWidth, active, value, tooltip } = this.props;
+
     const className = classNames('search-navigator-facet button-link', this.props.className, {
       active,
     });
index 97ef4850aa8e7a28720babeeb4879d2ea16f0c95..9d47ddf89634d8f14c6f1af0798da5a7cb2c0f36 100644 (file)
@@ -53,7 +53,6 @@ exports[`should display all selected items 1`] = `
       active={true}
       halfWidth={false}
       key="a"
-      loading={false}
       name="a"
       onClick={[Function]}
       stat="10"
@@ -64,7 +63,6 @@ exports[`should display all selected items 1`] = `
       active={true}
       halfWidth={false}
       key="b"
-      loading={false}
       name="b"
       onClick={[Function]}
       stat="5"
@@ -84,7 +82,6 @@ exports[`should display all selected items 1`] = `
       active={true}
       halfWidth={false}
       key="c"
-      loading={false}
       name="c"
       onClick={[Function]}
       stat="3"
@@ -133,7 +130,6 @@ exports[`should render 1`] = `
       active={false}
       halfWidth={false}
       key="a"
-      loading={false}
       name="a"
       onClick={[Function]}
       stat="10"
@@ -144,7 +140,6 @@ exports[`should render 1`] = `
       active={false}
       halfWidth={false}
       key="b"
-      loading={false}
       name="b"
       onClick={[Function]}
       stat="8"
@@ -155,7 +150,6 @@ exports[`should render 1`] = `
       active={false}
       halfWidth={false}
       key="c"
-      loading={false}
       name="c"
       onClick={[Function]}
       stat="1"
@@ -204,7 +198,6 @@ exports[`should search 1`] = `
       active={false}
       halfWidth={false}
       key="d"
-      loading={false}
       name="d"
       onClick={[Function]}
       stat="7"
@@ -215,7 +208,6 @@ exports[`should search 1`] = `
       active={false}
       halfWidth={false}
       key="e"
-      loading={false}
       name="e"
       onClick={[Function]}
       stat="3"
@@ -266,7 +258,6 @@ exports[`should search 2`] = `
       active={false}
       halfWidth={false}
       key="d"
-      loading={false}
       name="d"
       onClick={[Function]}
       stat="7"
@@ -277,7 +268,6 @@ exports[`should search 2`] = `
       active={false}
       halfWidth={false}
       key="e"
-      loading={false}
       name="e"
       onClick={[Function]}
       stat="3"
@@ -288,7 +278,6 @@ exports[`should search 2`] = `
       active={false}
       halfWidth={false}
       key="f"
-      loading={false}
       name="f"
       onClick={[Function]}
       stat="5"
@@ -339,7 +328,6 @@ exports[`should search 3`] = `
       active={false}
       halfWidth={false}
       key="a"
-      loading={false}
       name="a"
       onClick={[Function]}
       stat="10"
@@ -350,7 +338,6 @@ exports[`should search 3`] = `
       active={false}
       halfWidth={false}
       key="b"
-      loading={false}
       name="b"
       onClick={[Function]}
       stat="8"
@@ -361,7 +348,6 @@ exports[`should search 3`] = `
       active={false}
       halfWidth={false}
       key="c"
-      loading={false}
       name="c"
       onClick={[Function]}
       stat="1"
index d48be1248f72ee2e36094d82277be1c0ebd113d9..f2c458d83273110a6defe6990e8db728a4f901bb 100644 (file)
@@ -295,51 +295,6 @@ button.search-navigator-facet:focus,
   padding: 0 10px 16px;
 }
 
-.search-navigator-date-facet-selection {
-  position: relative;
-  padding-left: var(--gridSize);
-  font-size: var(--smallFontSize);
-}
-
-.search-navigator-date-facet-selection:before,
-.search-navigator-date-facet-selection:after {
-  display: table;
-  content: '';
-  line-height: 0;
-}
-
-.search-navigator-date-facet-selection:after {
-  clear: both;
-}
-
-.search-navigator-date-facet-selection .date-input-control-input {
-  width: 115px !important;
-}
-
-.search-navigator-date-facet-selection-dropdown-left {
-  float: left;
-  border-bottom: none;
-}
-
-.search-navigator-date-facet-selection-dropdown-right {
-  float: right;
-  border-bottom: none;
-}
-
-.search-navigator-date-facet-selection-input-left {
-  position: absolute;
-  left: 0;
-  width: 100px;
-  visibility: hidden;
-}
-
-.search-navigator-date-facet-selection-input-right {
-  position: absolute;
-  right: 0;
-  width: 100px;
-  visibility: hidden;
-}
-
 .search-navigator-filters {
   position: relative;
   padding: 5px 10px;
index a28b90793e28a1ac0977f436f54a7143a789fffb..52e9335dabadb5dd2e1e47bc8a9d0894014af74a 100644 (file)
 import { colors } from '../app/theme';
 import { AlmKeys } from '../types/alm-settings';
 import { ComponentQualifier } from '../types/component';
-import { IssueScope, IssueSeverity, IssueType } from '../types/issues';
+import { IssueResolution, IssueScope, IssueSeverity, IssueType } from '../types/issues';
 import { RuleType } from '../types/types';
 
 export const SEVERITIES = Object.values(IssueSeverity);
-export const STATUSES = ['OPEN', 'REOPENED', 'CONFIRMED', 'RESOLVED', 'CLOSED'];
+
+export const STATUSES = ['OPEN', 'CONFIRMED', 'REOPENED', 'RESOLVED', 'CLOSED'];
+
 export const ISSUE_TYPES: IssueType[] = [
   IssueType.Bug,
   IssueType.Vulnerability,
   IssueType.CodeSmell,
   IssueType.SecurityHotspot,
 ];
+
+export const RESOLUTIONS = [
+  IssueResolution.Unresolved,
+  IssueResolution.FalsePositive,
+  IssueResolution.Fixed,
+  IssueResolution.Removed,
+  IssueResolution.WontFix,
+];
+
 export const SOURCE_SCOPES = [
   { scope: IssueScope.Main, qualifier: ComponentQualifier.File },
   { scope: IssueScope.Test, qualifier: ComponentQualifier.TestFile },
 ];
+
 export const RULE_TYPES: RuleType[] = ['BUG', 'VULNERABILITY', 'CODE_SMELL', 'SECURITY_HOTSPOT'];
+
 export const RULE_STATUSES = ['READY', 'BETA', 'DEPRECATED'];
 
 export const RATING_COLORS = [
index fe626de3c3a4fc8647c1e1418ddcbe1df4794e4e..a09e5e8c3ac207853406bcc7d937728d77f05abb 100644 (file)
@@ -30,6 +30,16 @@ export function mockReferencedRule(overrides: Partial<ReferencedRule> = {}): Ref
   };
 }
 
+export function mockIssueAuthors(overrides: string[] = []): string[] {
+  return [
+    'email1@sonarsource.com',
+    'email2@sonarsource.com',
+    'email3@sonarsource.com',
+    'email4@sonarsource.com',
+    ...overrides,
+  ];
+}
+
 export function mockIssueChangelog(overrides: Partial<IssueChangelog> = {}): IssueChangelog {
   return {
     creationDate: '2018-10-01',
index ac53261316d25a3aa406567d27da7031be89afe6..fa3de9cd662218262a51ceb240535e63892b7269 100644 (file)
@@ -57,6 +57,12 @@ module.exports = plugin(({ addUtilities, theme }) => {
       'line-height': theme('fontSize').sm[1],
       'font-weight': theme('fontWeight.regular'),
     },
+    '.body-xs': {
+      'font-family': theme('fontFamily.sans'),
+      'font-size': theme('fontSize.xs'),
+      'line-height': theme('fontSize').xs[1],
+      'font-weight': theme('fontWeight.regular'),
+    },
     '.body-sm-highlight': {
       'font-family': theme('fontFamily.sans'),
       'font-size': theme('fontSize.sm'),
index f32a56017bb16227e8d3a61d323a7924eddc996f..d8c84c29da16f78736138466e3588837087e1320 100644 (file)
@@ -35,6 +35,7 @@ module.exports = {
     // Define font sizes
     fontSize: {
       code: ['0.875rem', '1.125rem'], // 14px / 18px
+      xs: ['0.75rem', '1rem'], // 12px / 16px
       sm: ['0.875rem', '1.25rem'], // 14px / 20px
       base: ['1rem', '1.5rem'], // 16px / 24px
       md: ['1.313rem', '1.75rem'], // 21px / 28px
@@ -62,8 +63,10 @@ module.exports = {
       3: '3',
       4: '4',
     },
-    // No responsive breakpoint for the webapp
-    screens: {},
+    screens: {
+      sm: '1280px',
+      lg: '1920px',
+    },
     // Defined spacing values based on our grid size
     spacing: {
       0: '0',
@@ -72,6 +75,7 @@ module.exports = {
       2: '0.5rem', // 8px
       3: '0.75rem', // 12px
       4: '1rem', // 16px
+      5: '1.25rem', // 20px
       6: '1.5rem', // 24px
       7: '1.75rem', // 28px
       8: '2rem', // 32px
index 1b751ec1e8d518a8e47b6639daf13344126ae523..c63734107ca3dc52cdc1b89e100524974b61cf60 100644 (file)
@@ -137,6 +137,7 @@ navigation=Navigation
 never=Never
 new=New
 new_name=New name
+next_=next
 none=None
 no_tags=No tags
 not_now=Not now
@@ -150,6 +151,7 @@ password=Password
 path=Path
 permalink=Permanent Link
 plugin=Plugin
+previous_=previous
 project=Project
 project_x=Project: {0}
 projects=Projects