From 40e061b25b152d7704a989240c12191317a81f3f Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Tue, 30 May 2023 16:00:31 +0200 Subject: [PATCH] SONAR-19345 Issues page - list view: new facets using MIUI elements --- .../design-system/src/components/BarChart.tsx | 2 +- .../src/components/DatePicker.tsx | 82 ++- .../src/components/DateRangePicker.tsx | 2 +- .../design-system/src/components/FacetBox.tsx | 26 +- .../src/components/FacetItem.tsx | 31 +- .../src/components/FlagMessage.tsx | 2 + .../src/components/InputSearch.tsx | 4 +- .../src/components/KeyboardHintKeys.tsx | 46 +- .../components/__tests__/DatePicker-test.tsx | 44 +- .../components/__tests__/FacetBox-test.tsx | 6 +- .../components/__tests__/FacetItem-test.tsx | 22 +- .../__tests__/KeyboardHintKeys-test.tsx | 15 +- .../__snapshots__/KeyboardHint-test.tsx.snap | 76 +-- .../KeyboardHintKeys-test.tsx.snap | 37 +- .../src/components/icons/TestFileIcon.tsx | 2 + .../src/components/icons/index.ts | 1 + .../main/js/api/mocks/IssuesServiceMock.ts | 68 ++- .../coding-rules/components/FacetsList.tsx | 2 +- .../coding-rules/components/StandardFacet.tsx | 540 ++++++++++++++++++ .../js/apps/issues/__tests__/IssuesApp-it.tsx | 35 +- .../apps/issues/components/AssigneeSelect.tsx | 3 +- .../js/apps/issues/components/IssuesApp.tsx | 213 ++++--- .../js/apps/issues/sidebar/AssigneeFacet.tsx | 42 +- .../js/apps/issues/sidebar/AuthorFacet.tsx | 27 +- .../apps/issues/sidebar/CreationDateFacet.tsx | 220 ++++--- .../js/apps/issues/sidebar/DirectoryFacet.tsx | 17 +- .../apps/issues/sidebar/FacetItemsColumns.tsx | 29 + .../js/apps/issues/sidebar/FacetItemsList.tsx | 47 ++ .../main/js/apps/issues/sidebar/FileFacet.tsx | 18 +- .../js/apps/issues/sidebar/FiltersHeader.tsx | 46 ++ .../js/apps/issues/sidebar/LanguageFacet.tsx | 10 +- .../js/apps/issues/sidebar/ListStyleFacet.tsx | 486 ++++++++++++++++ .../issues/sidebar/ListStyleFacetFooter.tsx | 86 +++ .../issues/sidebar/MultipleSelectionHint.tsx | 37 ++ .../js/apps/issues/sidebar/PeriodFilter.tsx | 40 +- .../js/apps/issues/sidebar/ProjectFacet.tsx | 22 +- .../apps/issues/sidebar/ResolutionFacet.tsx | 77 ++- .../main/js/apps/issues/sidebar/RuleFacet.tsx | 7 +- .../js/apps/issues/sidebar/ScopeFacet.tsx | 116 ++-- .../js/apps/issues/sidebar/SeverityFacet.tsx | 82 ++- .../main/js/apps/issues/sidebar/Sidebar.tsx | 204 ++++--- .../js/apps/issues/sidebar/StandardFacet.tsx | 289 +++++----- .../js/apps/issues/sidebar/StatusFacet.tsx | 82 ++- .../main/js/apps/issues/sidebar/TagFacet.tsx | 40 +- .../main/js/apps/issues/sidebar/TypeFacet.tsx | 76 +-- .../js/apps/issues/sidebar/VariantFacet.tsx | 89 +-- .../sidebar/__tests__/ListStyleFacet-test.tsx | 219 +++++++ .../__tests__/ListStyleFacetFooter-test.tsx | 83 +++ .../issues/sidebar/__tests__/Sidebar-it.tsx | 15 +- .../ListStyleFacet-test.tsx.snap | 464 +++++++++++++++ .../src/main/js/apps/issues/styles.css | 12 +- .../src/main/js/apps/issues/test-utils.tsx | 68 +-- .../main/js/components/facet/FacetItem.tsx | 3 +- .../ListStyleFacet-test.tsx.snap | 14 - .../main/js/components/search-navigator.css | 45 -- .../src/main/js/helpers/constants.ts | 17 +- .../src/main/js/helpers/mocks/issues.ts | 10 + server/sonar-web/tailwind-utilities.js | 6 + server/sonar-web/tailwind.base.config.js | 8 +- .../resources/org/sonar/l10n/core.properties | 2 + 60 files changed, 3363 insertions(+), 1051 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsColumns.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/FacetItemsList.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/FiltersHeader.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacetFooter.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/MultipleSelectionHint.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacet-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/ListStyleFacetFooter-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap diff --git a/server/sonar-web/design-system/src/components/BarChart.tsx b/server/sonar-web/design-system/src/components/BarChart.tsx index 9b460674e69..91a01174c97 100644 --- a/server/sonar-web/design-system/src/components/BarChart.tsx +++ b/server/sonar-web/design-system/src/components/BarChart.tsx @@ -24,7 +24,7 @@ import { themeColor } from '../helpers'; interface DataPoint { description: string; - tooltip?: string; + tooltip?: string | JSX.Element; x: number; y: number; } diff --git a/server/sonar-web/design-system/src/components/DatePicker.tsx b/server/sonar-web/design-system/src/components/DatePicker.tsx index e80795cbad2..0bb34725c2d 100644 --- a/server/sonar-web/design-system/src/components/DatePicker.tsx +++ b/server/sonar-web/design-system/src/components/DatePicker.tsx @@ -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 { 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 { 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 { readOnly ref={inputRef} size={size} - title={this.props.valueFormatter(selectedDay)} + title={valueFormatter(selectedDay)} type="text" - value={this.props.valueFormatter(selectedDay)} + value={valueFormatter(selectedDay)} /> + + {selectedDay !== undefined && showClearButton && ( { 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" - /> - { - if (value) { - goToMonth(value.value); - } - }} - options={months} - size="full" - value={months.find((m) => isSameMonth(m.value, displayMonth))} - /> - { - if (value) { - goToMonth(value.value); + onClick={() => { + if (previousMonth) { + goToMonth(previousMonth); } }} - options={years} - size="full" - value={years.find((y) => isSameYear(y.value, displayMonth))} + size="small" /> + + + { + if (value) { + goToMonth(value.value); + } + }} + options={months} + size="full" + value={months.find((m) => isSameMonth(m.value, displayMonth))} + /> + + + + { + if (value) { + goToMonth(value.value); + } + }} + options={years} + size="full" + value={years.find((y) => isSameYear(y.value, displayMonth))} + /> + + nextMonth && goToMonth(nextMonth)} + onClick={() => { + if (nextMonth) { + goToMonth(nextMonth); + } + }} size="small" /> diff --git a/server/sonar-web/design-system/src/components/DateRangePicker.tsx b/server/sonar-web/design-system/src/components/DateRangePicker.tsx index 132deafcd04..01e73b17baf 100644 --- a/server/sonar-web/design-system/src/components/DateRangePicker.tsx +++ b/server/sonar-web/design-system/src/components/DateRangePicker.tsx @@ -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 { diff --git a/server/sonar-web/design-system/src/components/FacetBox.tsx b/server/sonar-web/design-system/src/components/FacetBox.tsx index 2e100268542..0366e6fe69e 100644 --- a/server/sonar-web/design-system/src/components/FacetBox.tsx +++ b/server/sonar-web/design-system/src/components/FacetBox.tsx @@ -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 ( - +
@@ -116,7 +127,7 @@ export function FacetBox(props: FacetBoxProps) {
{open && ( -
+
{children}
)} @@ -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')}; `; diff --git a/server/sonar-web/design-system/src/components/FacetItem.tsx b/server/sonar-web/design-system/src/components/FacetItem.tsx index 0d318feec0a..62d5e38b150 100644 --- a/server/sonar-web/design-system/src/components/FacetItem.tsx +++ b/server/sonar-web/design-system/src/components/FacetItem.tsx @@ -26,8 +26,9 @@ import { ButtonProps, ButtonSecondary } from './buttons'; export type FacetItemProps = Omit & { 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) => { event.preventDefault(); @@ -56,12 +58,15 @@ export function FacetItem({ return ( @@ -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')}; } diff --git a/server/sonar-web/design-system/src/components/FlagMessage.tsx b/server/sonar-web/design-system/src/components/FlagMessage.tsx index 3a3aed03c94..72ef3a9808f 100644 --- a/server/sonar-web/design-system/src/components/FlagMessage.tsx +++ b/server/sonar-web/design-system/src/components/FlagMessage.tsx @@ -90,6 +90,8 @@ export function FlagMessage(props: Props & React.HTMLAttributes) ); } +FlagMessage.displayName = 'FlagMessage'; // so that tests don't see the obfuscated production name + export const StyledFlag = styled.div<{ variantInfo: VariantInformation; }>` diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx index 3c0233ef392..354fab3b5d2 100644 --- a/server/sonar-web/design-system/src/components/InputSearch.tsx +++ b/server/sonar-web/design-system/src/components/InputSearch.tsx @@ -164,7 +164,7 @@ export function InputSearch({ @@ -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); diff --git a/server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx b/server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx index 7aa37f175d3..230a5dc7231 100644 --- a/server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx +++ b/server/sonar-web/design-system/src/components/KeyboardHintKeys.tsx @@ -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]: , + [Key.ArrowLeft]: , + [Key.ArrowRight]: , + [Key.ArrowUp]: , + [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 {key}; } - return {getKey(key)}; + return {mappedKeys[key as keyof typeof mappedKeys] || key}; }); return
{keys}
; @@ -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 ; - case Key.ArrowDown: - return ; - case Key.ArrowLeft: - return ; - case Key.ArrowRight: - return ; - default: - return key; - } -} diff --git a/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx index 8edfa140e2a..01aa68be4c7 100644 --- a/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/DatePicker-test.tsx @@ -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 = {}) { diff --git a/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx index 88dd9499885..434a6147e5c 100644 --- a/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx @@ -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(); diff --git a/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx index 12703d37f00..a916181172a 100644 --- a/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx @@ -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:
Foo
, small: true }); + + expect(screen.getByRole('checkbox')).not.toHaveAttribute('aria-label'); +}); + function renderComponent(props: Partial = {}) { return render(); } diff --git a/server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx b/server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx index 4d1ff44cede..b26ee4e2801 100644 --- a/server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/KeyboardHintKeys-test.tsx @@ -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(); }); diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap index 081af387a40..6ac0d945eef 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHint-test.tsx.snap @@ -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); }
@@ -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); }
@@ -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); -} -
- + command
@@ -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); -} -
- + click
@@ -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); -} -
- + click
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap index 907e3994ef0..be6c83072f5 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/KeyboardHintKeys-test.tsx.snap @@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -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); }
@@ -361,9 +361,7 @@ exports[`should render a default text if no keys match 1`] = ` + - + click
@@ -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); }
+ + Use + + + Ctrl + + + + + @@ -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); }
diff --git a/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx index fae7278a2f6..9ab29f18b37 100644 --- a/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx +++ b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx @@ -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 ( { @@ -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> => { @@ -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) => { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx index a9b52ce93e8..2d43646bba3 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx @@ -20,7 +20,6 @@ import * as React from 'react'; import { Profile } from '../../../api/quality-profiles'; import { Dict } from '../../../types/types'; -import StandardFacet from '../../issues/sidebar/StandardFacet'; import { Facets, OpenFacets, Query } from '../query'; import ActivationSeverityFacet from './ActivationSeverityFacet'; import AvailableSinceFacet from './AvailableSinceFacet'; @@ -29,6 +28,7 @@ import InheritanceFacet from './InheritanceFacet'; import LanguageFacet from './LanguageFacet'; import ProfileFacet from './ProfileFacet'; import RepositoryFacet from './RepositoryFacet'; +import { StandardFacet } from './StandardFacet'; import StatusFacet from './StatusFacet'; import TagFacet from './TagFacet'; import TemplateFacet from './TemplateFacet'; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx new file mode 100644 index 00000000000..2e63ec94fae --- /dev/null +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/StandardFacet.tsx @@ -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 | undefined; + fetchingCwe: boolean; + fetchingOwaspTop10: boolean; + 'fetchingOwaspTop10-2021': boolean; + fetchingSonarSourceSecurity: boolean; + loadSearchResultCount?: (property: string, changes: Partial) => Promise; + onChange: (changes: Partial) => void; + onToggle: (property: string) => void; + open: boolean; + owaspTop10: string[]; + owaspTop10Open: boolean; + owaspTop10Stats: Dict | undefined; + 'owaspTop10-2021': string[]; + 'owaspTop10-2021Open': boolean; + 'owaspTop10-2021Stats': Dict | undefined; + query: Partial; + sonarsourceSecurity: string[]; + sonarsourceSecurityOpen: boolean; + sonarsourceSecurityStats: Dict | 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 { + 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, + 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 ( +
+ {translate('no_results')} +
+ ); + } + + const getStat = (category: string) => { + return stats ? stats[category] : undefined; + }; + + return ( + + {categories.map((category) => ( + + ))} + + ); + }; + + renderHint = (statsProp: StatsProp, valuesProp: ValuesProp) => { + const stats = this.props[statsProp] ?? {}; + const values = this.props[valuesProp]; + + return ; + }; + + 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 ( + <> + + {limitedList.map((item) => ( + + ))} + + + {selectedBelowLimit.length > 0 && ( + <> + {!allItemShown &&
⋯
} + + {selectedBelowLimit.map((item) => ( + + ))} + + + )} + + {!allItemShown && ( + 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 ( + <> + + + renderSonarSourceSecurityCategory(this.state.standards, item) + )} + /> + + {sonarsourceSecurityOpen && ( + <> + {this.renderSonarSourceSecurityList()} + {this.renderSonarSourceSecurityHint()} + + )} + + + + + renderOwaspTop102021Category(this.state.standards, item) + )} + /> + + {owaspTop102021Open && ( + <> + {this.renderOwaspTop102021List()} + {this.renderOwaspTop102021Hint()} + + )} + + + + renderOwaspTop10Category(this.state.standards, item))} + /> + + {owaspTop10Open && ( + <> + {this.renderOwaspTop10List()} + {this.renderOwaspTop10Hint()} + + )} + + + + 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 ( + + + + {open && this.renderSubFacets()} + + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx index 34de8e4547d..a0282be1eea 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx @@ -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/, diff --git a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx index f62a4bb2c9e..5da901f7a48 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx @@ -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 searchAssignees(query) .then(({ results }) => results.map((r) => { - const userInfo = r.name || r.login; + const userInfo = r.name ?? r.login; return { avatar: r.avatar, diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 8ace857ba5d..2326968dc26 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -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 { 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 { 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 { 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 { !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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { openSelectedIssue = () => { const { selected } = this.state; + if (selected) { this.openIssue(selected); } @@ -437,6 +456,7 @@ export class App extends React.PureComponent { 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 { const parameters: Dict = { ...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 { 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 { ) { return true; } + return pageIssues.some((issue) => issue.key === openIssueKey); }); } else { @@ -510,9 +532,11 @@ export class App extends React.PureComponent { 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 { 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 { const recursiveFetch = (p: number, prevIssues: Issue[]): Promise => { 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 { 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 { isFiltered = () => { const serialized = serializeQuery(this.state.query); + return !areQueriesEqual(serialized, DEFAULT_QUERY); }; @@ -633,7 +663,9 @@ export class App extends React.PureComponent { 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 { } 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 { 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 { 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 { 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 { 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 { 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 { // 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 { 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 { 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 { 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 { if (openIssue) { return { locationsNavigator: true, selectedLocationIndex: index }; } + return null; }); } @@ -852,6 +894,7 @@ export class App extends React.PureComponent { 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 { thirdState={thirdState} title={translate('issues.select_all_issues')} /> +