@@ -24,7 +24,7 @@ import { themeColor } from '../helpers'; | |||
interface DataPoint { | |||
description: string; | |||
tooltip?: string; | |||
tooltip?: string | JSX.Element; | |||
x: number; | |||
y: number; | |||
} |
@@ -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> |
@@ -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> { |
@@ -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,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')}; | |||
} |
@@ -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; | |||
}>` |
@@ -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); | |||
@@ -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; | |||
} | |||
} |
@@ -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']> = {}) { |
@@ -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(); | |||
@@ -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} />); | |||
} |
@@ -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(); | |||
}); | |||
@@ -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> |
@@ -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> |
@@ -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 |
@@ -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'; |
@@ -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) => { |
@@ -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'; |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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/, |
@@ -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, |
@@ -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 |
@@ -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} |
@@ -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} |
@@ -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); |
@@ -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')} |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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')} |
@@ -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> | |||
); | |||
} |
@@ -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); |
@@ -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); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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; | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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')} |
@@ -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> | |||
); | |||
} |
@@ -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; | |||
}; | |||
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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); |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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} |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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); | |||
} |
@@ -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} /> | |||
); | |||
} |
@@ -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={{}} |
@@ -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> | |||
`; |
@@ -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 { |
@@ -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() { |
@@ -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, | |||
}); |
@@ -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" |
@@ -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; |
@@ -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 = [ |
@@ -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', |
@@ -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'), |
@@ -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 |
@@ -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 |