Browse Source

SONAR-19345 Issues page - list view: new facets using MIUI elements

tags/10.1.0.73491
David Cho-Lerat 1 year ago
parent
commit
40e061b25b
60 changed files with 3363 additions and 1051 deletions
  1. 1
    1
      server/sonar-web/design-system/src/components/BarChart.tsx
  2. 54
    28
      server/sonar-web/design-system/src/components/DatePicker.tsx
  3. 1
    1
      server/sonar-web/design-system/src/components/DateRangePicker.tsx
  4. 21
    5
      server/sonar-web/design-system/src/components/FacetBox.tsx
  5. 26
    5
      server/sonar-web/design-system/src/components/FacetItem.tsx
  6. 2
    0
      server/sonar-web/design-system/src/components/FlagMessage.tsx
  7. 3
    1
      server/sonar-web/design-system/src/components/InputSearch.tsx
  8. 15
    31
      server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx
  9. 36
    8
      server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx
  10. 3
    3
      server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx
  11. 17
    5
      server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx
  12. 3
    12
      server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx
  13. 5
    71
      server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap
  14. 23
    14
      server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap
  15. 2
    0
      server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx
  16. 1
    0
      server/sonar-web/design-system/src/components/icons/index.ts
  17. 46
    22
      server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
  18. 1
    1
      server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx
  19. 540
    0
      server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx
  20. 29
    6
      server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
  21. 2
    1
      server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
  22. 146
    67
      server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
  23. 26
    16
      server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
  24. 15
    12
      server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx
  25. 108
    112
      server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx
  26. 10
    7
      server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx
  27. 29
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx
  28. 47
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx
  29. 11
    7
      server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx
  30. 46
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx
  31. 7
    3
      server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
  32. 486
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx
  33. 86
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx
  34. 37
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx
  35. 18
    22
      server/sonar-web/src/main/js/apps/issues/sidebar/PeriodFilter.tsx
  36. 14
    8
      server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
  37. 36
    41
      server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx
  38. 5
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
  39. 56
    60
      server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx
  40. 52
    30
      server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.tsx
  41. 132
    72
      server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
  42. 152
    137
      server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
  43. 51
    31
      server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.tsx
  44. 15
    25
      server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
  45. 41
    35
      server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx
  46. 49
    40
      server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx
  47. 219
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx
  48. 83
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.tsx
  49. 10
    5
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
  50. 464
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
  51. 4
    8
      server/sonar-web/src/main/js/apps/issues/styles.css
  52. 36
    32
      server/sonar-web/src/main/js/apps/issues/test-utils.tsx
  53. 2
    1
      server/sonar-web/src/main/js/components/facet/FacetItem.tsx
  54. 0
    14
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
  55. 0
    45
      server/sonar-web/src/main/js/components/search-navigator.css
  56. 15
    2
      server/sonar-web/src/main/js/helpers/constants.ts
  57. 10
    0
      server/sonar-web/src/main/js/helpers/mocks/issues.ts
  58. 6
    0
      server/sonar-web/tailwind-utilities.js
  59. 6
    2
      server/sonar-web/tailwind.base.config.js
  60. 2
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 1
- 1
server/sonar-web/design-system/src/components/BarChart.tsx View File

@@ -24,7 +24,7 @@ import { themeColor } from '../helpers';

interface DataPoint {
description: string;
tooltip?: string;
tooltip?: string | JSX.Element;
x: number;
y: number;
}

+ 54
- 28
server/sonar-web/design-system/src/components/DatePicker.tsx View 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>

+ 1
- 1
server/sonar-web/design-system/src/components/DateRangePicker.tsx View 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> {

+ 21
- 5
server/sonar-web/design-system/src/components/FacetBox.tsx View 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')};
`;


+ 26
- 5
server/sonar-web/design-system/src/components/FacetItem.tsx View 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')};
}

+ 2
- 0
server/sonar-web/design-system/src/components/FlagMessage.tsx View 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;
}>`

+ 3
- 1
server/sonar-web/design-system/src/components/InputSearch.tsx View 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);


+ 15
- 31
server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx View File

@@ -17,17 +17,23 @@
* 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;
}
}

+ 36
- 8
server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx View 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']> = {}) {

+ 3
- 3
server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx View 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();


+ 17
- 5
server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx View 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} />);
}

+ 3
- 12
server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx View File

@@ -21,24 +21,15 @@
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();
});


+ 5
- 71
server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap View 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>

+ 23
- 14
server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap View 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>

+ 2
- 0
server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx View 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

+ 1
- 0
server/sonar-web/design-system/src/components/icons/index.ts View 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';

+ 46
- 22
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts View 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) => {

+ 1
- 1
server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx View 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';

+ 540
- 0
server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx View File

@@ -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>
);
}
}

+ 29
- 6
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx View 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/,

+ 2
- 1
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx View 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,

+ 146
- 67
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx View 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

+ 26
- 16
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx View File

@@ -17,16 +17,17 @@
* 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}

+ 15
- 12
server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx View File

@@ -17,17 +17,20 @@
* 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}

+ 108
- 112
server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx View File

@@ -17,23 +17,19 @@
* 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);

+ 10
- 7
server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx View File

@@ -17,11 +17,11 @@
* 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')}

+ 29
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx View File

@@ -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>
);
}

+ 47
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx View File

@@ -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>
);
}

+ 11
- 7
server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx View File

@@ -17,11 +17,11 @@
* 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')}

+ 46
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx View File

@@ -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>
);
}

+ 7
- 3
server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx View File

@@ -17,16 +17,17 @@
* 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);

+ 486
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx View File

@@ -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);
}

+ 86
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx View File

@@ -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>
);
}

+ 37
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx View File

@@ -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;
}

+ 18
- 22
server/sonar-web/src/main/js/apps/issues/sidebar/PeriodFilter.tsx View File

@@ -17,18 +17,16 @@
* 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>
);
}

+ 14
- 8
server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx View File

@@ -17,17 +17,19 @@
* 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')}

+ 36
- 41
server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx View File

@@ -17,17 +17,16 @@
* 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>
);
}

+ 5
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx View File

@@ -17,15 +17,16 @@
* 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;
};


+ 56
- 60
server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx View File

@@ -17,18 +17,16 @@
* 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>
);
}

+ 52
- 30
server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.tsx View File

@@ -17,17 +17,23 @@
* 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>
);
}

+ 132
- 72
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx View 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);

+ 152
- 137
server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx View File

@@ -19,16 +19,10 @@
*/
/* 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>
);
}

+ 51
- 31
server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.tsx View File

@@ -17,17 +17,23 @@
* 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>
);
}

+ 15
- 25
server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx View File

@@ -17,17 +17,17 @@
* 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}

+ 41
- 35
server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx View File

@@ -17,18 +17,16 @@
* 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>
);
}

+ 49
- 40
server/sonar-web/src/main/js/apps/issues/sidebar/VariantFacet.tsx View File

@@ -17,16 +17,15 @@
* 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>
);
}

+ 219
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx View File

@@ -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);
}

+ 83
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.tsx View File

@@ -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} />
);
}

+ 10
- 5
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx View 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={{}}

+ 464
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap View File

@@ -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>
`;

+ 4
- 8
server/sonar-web/src/main/js/apps/issues/styles.css View File

@@ -68,18 +68,14 @@
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 {

+ 36
- 32
server/sonar-web/src/main/js/apps/issues/test-utils.tsx View 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() {

+ 2
- 1
server/sonar-web/src/main/js/components/facet/FacetItem.tsx View 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,
});

+ 0
- 14
server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap View 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"

+ 0
- 45
server/sonar-web/src/main/js/components/search-navigator.css View 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;

+ 15
- 2
server/sonar-web/src/main/js/helpers/constants.ts View File

@@ -20,22 +20,35 @@
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 = [

+ 10
- 0
server/sonar-web/src/main/js/helpers/mocks/issues.ts View 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',

+ 6
- 0
server/sonar-web/tailwind-utilities.js View 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'),

+ 6
- 2
server/sonar-web/tailwind.base.config.js View 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

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View 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

Loading…
Cancel
Save