From: David Cho-Lerat Date: Thu, 11 May 2023 14:28:47 +0000 (+0200) Subject: SONAR-19163 Issues page: create filter components X-Git-Tag: 10.1.0.73491~308 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=166b757598f82842941ef85b360cef70254f6519;p=sonarqube.git SONAR-19163 Issues page: create filter components --- diff --git a/server/sonar-web/design-system/src/components/FacetBox.tsx b/server/sonar-web/design-system/src/components/FacetBox.tsx new file mode 100644 index 00000000000..db57773885d --- /dev/null +++ b/server/sonar-web/design-system/src/components/FacetBox.tsx @@ -0,0 +1,173 @@ +/* + * 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 styled from '@emotion/styled'; +import classNames from 'classnames'; +import { uniqueId } from 'lodash'; +import * as React from 'react'; +import tw from 'twin.macro'; +import { themeColor } from '../helpers'; +import { Badge } from './Badge'; +import { DeferredSpinner } from './DeferredSpinner'; +import { InteractiveIcon } from './InteractiveIcon'; +import Tooltip from './Tooltip'; +import { BareButton } from './buttons'; +import { OpenCloseIndicator } from './icons'; +import { CloseIcon } from './icons/CloseIcon'; + +export interface FacetBoxProps { + ariaLabel?: string; + children: React.ReactNode; + className?: string; + clearIconLabel?: string; + count?: number; + countLabel?: string; + disabled?: boolean; + id?: string; + inner?: boolean; + loading?: boolean; + name: string; + onClear?: () => void; + onClick?: (isOpen: boolean) => void; + open?: boolean; +} + +export function FacetBox(props: FacetBoxProps) { + const { + ariaLabel, + children, + className, + clearIconLabel, + count, + countLabel, + disabled = false, + id: idProp, + inner = false, + loading = false, + name, + onClear, + onClick, + open = false, + } = props; + + const clearable = !disabled && Boolean(onClear) && count; + const counter = count ?? 0; + const expandable = !disabled && Boolean(onClick); + const id = React.useMemo(() => idProp ?? uniqueId('filter-facet-'), [idProp]); + + return ( + +
+ { + if (!disabled) { + onClick?.(!open); + } + }} + > + {expandable && } + + {name} + + + {} + + {counter > 0 && ( + + + {counter} + + + {clearable && ( + + + + )} + + )} +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +const Accordion = styled.div<{ + inner?: boolean; +}>` + ${tw`sw-flex-col`}; + ${tw`sw-flex`}; + ${tw`sw-gap-3`}; + + ${({ inner }) => (inner ? tw`sw-gap-1 sw-ml-3` : '')}; +`; + +const BadgeAndIcons = styled.div` + ${tw`sw-flex`}; + ${tw`sw-gap-2`}; +`; + +const ChevronAndTitle = styled(BareButton)<{ + expandable?: boolean; +}>` + ${tw`sw-flex`}; + ${tw`sw-gap-1`}; + ${tw`sw-h-9`}; + ${tw`sw-items-center`}; + + cursor: ${({ expandable }) => (expandable ? 'pointer' : 'default')}; +`; + +const ClearIcon = styled(InteractiveIcon)` + --color: ${themeColor('dangerButton')}; +`; + +const Header = styled.div` + ${tw`sw-flex`}; + ${tw`sw-gap-3`}; + ${tw`sw-items-center`}; + ${tw`sw-justify-between`}; +`; + +const HeaderTitle = styled.span<{ + disabled?: boolean; +}>` + ${tw`sw-body-sm-highlight`}; + + color: ${({ disabled }) => + disabled ? themeColor('facetHeaderDisabled') : themeColor('facetHeader')}; + + cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'inherit')}; +`; diff --git a/server/sonar-web/design-system/src/components/FacetItem.tsx b/server/sonar-web/design-system/src/components/FacetItem.tsx new file mode 100644 index 00000000000..0d318feec0a --- /dev/null +++ b/server/sonar-web/design-system/src/components/FacetItem.tsx @@ -0,0 +1,116 @@ +/* + * 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 styled from '@emotion/styled'; +import * as React from 'react'; +import tw from 'twin.macro'; +import { themeBorder, themeColor, themeContrast } from '../helpers'; +import { ButtonProps, ButtonSecondary } from './buttons'; + +export type FacetItemProps = Omit & { + active?: boolean; + name: string; + onClick: (x: string, multiple?: boolean) => void; + stat?: React.ReactNode; + /** Textual version of `name` */ + tooltip?: string; + value: string; +}; + +export function FacetItem({ + active, + className, + disabled: disabledProp = false, + icon, + name, + onClick, + stat, + tooltip, + value, +}: FacetItemProps) { + const disabled = disabledProp || (stat as number) === 0; + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + + onClick(value, event.ctrlKey || event.metaKey); + }; + + return ( + + + {name} + {stat} + + + ); +} + +const StyledButton = styled(ButtonSecondary)<{ active?: boolean }>` + ${tw`sw-body-sm`}; + ${tw`sw-p-1`}; + ${tw`sw-rounded-1`}; + + --background: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')}; + --backgroundHover: ${({ active }) => (active ? themeColor('facetItemSelected') : 'transparent')}; + + --border: ${({ active }) => + active + ? themeBorder('default', 'facetItemSelectedBorder') + : themeBorder('default', 'transparent')}; + + &:hover { + --border: ${themeBorder('default', 'facetItemSelectedBorder')}; + } + + & span.container { + ${tw`sw-container`}; + ${tw`sw-flex`}; + ${tw`sw-items-center`}; + ${tw`sw-justify-between`}; + + & span.stat { + color: ${themeColor('facetItemLight')}; + } + } + + &:disabled { + background-color: transparent; + border-color: transparent; + + & span.container span.stat { + color: ${themeContrast('buttonDisabled')}; + } + + &:hover { + background-color: transparent; + border-color: transparent; + } + } +`; diff --git a/server/sonar-web/design-system/src/components/ToggleButton.tsx b/server/sonar-web/design-system/src/components/ToggleButton.tsx index 17383161cbf..4ae54df92cb 100644 --- a/server/sonar-web/design-system/src/components/ToggleButton.tsx +++ b/server/sonar-web/design-system/src/components/ToggleButton.tsx @@ -87,8 +87,8 @@ const OptionButton = styled(ButtonSecondary)<{ selected: boolean }>` color: ${(props) => (props.selected ? themeContrast('toggleHover') : themeContrast('toggle'))}; border: none; height: auto; - overflow: hidden; ${tw`sw-rounded-0`}; + ${tw`sw-truncate`}; &:first-of-type { ${tw`sw-rounded-l-2`}; diff --git a/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx new file mode 100644 index 00000000000..88dd9499885 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx @@ -0,0 +1,76 @@ +/* + * 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 { render } from '../../helpers/testUtils'; +import { FacetBox, FacetBoxProps } from '../FacetBox'; + +it('should render an empty disabled facet box', async () => { + const user = userEvent.setup(); + + const onClick = jest.fn(); + + renderComponent({ disabled: true, onClick }); + + expect(screen.getByRole('listitem')).toBeInTheDocument(); + + expect(screen.queryByRole('region')).not.toBeInTheDocument(); + + expect(screen.getByText('Test FacetBox')).toBeInTheDocument(); + + expect(screen.getByRole('button', { expanded: false })).toHaveAttribute('aria-disabled', 'true'); + + await user.click(screen.getByRole('button')); + + expect(onClick).not.toHaveBeenCalled(); +}); + +it('should render an inner expanded facet box with count', async () => { + const user = userEvent.setup(); + + const onClear = jest.fn(); + const onClick = jest.fn(); + + renderComponent({ + children: 'The panel', + count: 3, + inner: true, + onClear, + onClick, + open: true, + }); + + expect(screen.getByRole('region')).toBeInTheDocument(); + + expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { expanded: true })); + + expect(onClick).toHaveBeenCalledWith(false); +}); + +function renderComponent({ children, ...props }: Partial = {}) { + return render( + + {children} + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx b/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx new file mode 100644 index 00000000000..12703d37f00 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx @@ -0,0 +1,61 @@ +/* + * 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 { render } from '../../helpers/testUtils'; +import { FacetItem, FacetItemProps } from '../FacetItem'; + +it('should render a disabled facet item', async () => { + const user = userEvent.setup(); + + const onClick = jest.fn(); + + renderComponent({ disabled: true, onClick }); + + expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'true'); + + await user.click(screen.getByRole('listitem')); + + expect(onClick).not.toHaveBeenCalled(); +}); + +it('should render a non-disabled facet item', async () => { + const user = userEvent.setup(); + + const onClick = jest.fn(); + + renderComponent({ active: true, onClick, stat: 3, value: 'foo' }); + + expect(screen.getByRole('listitem')).toHaveAttribute('aria-disabled', 'false'); + + await user.click(screen.getByRole('listitem')); + + expect(onClick).toHaveBeenCalledWith('foo', false); + + await user.keyboard('{Meta>}'); + await user.click(screen.getByRole('listitem')); + + expect(onClick).toHaveBeenLastCalledWith('foo', true); +}); + +function renderComponent(props: Partial = {}) { + return render(); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx b/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx index d42cd875eee..c691211c7ea 100644 --- a/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx @@ -38,6 +38,10 @@ it('should render all options', async () => { expect(screen.getAllByRole('radio')).toHaveLength(3); + await user.click(screen.getByText('first')); + + expect(onChange).not.toHaveBeenCalled(); + await user.click(screen.getByText('has counter')); expect(onChange).toHaveBeenCalledWith(3); diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx index 18030488b64..28650a777c0 100644 --- a/server/sonar-web/design-system/src/components/buttons.tsx +++ b/server/sonar-web/design-system/src/components/buttons.tsx @@ -38,7 +38,7 @@ export interface ButtonProps extends AllowedButtonAttributes { disabled?: boolean; icon?: React.ReactNode; innerRef?: React.Ref; - onClick?: VoidFunction; + onClick?: (event?: React.MouseEvent) => void; preventDefault?: boolean; reloadDocument?: LinkProps['reloadDocument']; @@ -61,7 +61,7 @@ class Button extends React.PureComponent { } if (onClick && !disabled) { - onClick(); + onClick(event); } }; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index a4af02dcab0..37ee85e8919 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -30,6 +30,8 @@ export { Dropdown } from './Dropdown'; export * from './DropdownMenu'; export { DropdownToggler } from './DropdownToggler'; export * from './DuplicationsIndicator'; +export * from './FacetBox'; +export * from './FacetItem'; export { FailedQGConditionLink } from './FailedQGConditionLink'; export { FlagMessage } from './FlagMessage'; export * from './GenericAvatar'; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 3683139b845..6a2f8bd5e95 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -349,6 +349,7 @@ export const lightTheme = { // facets facetHeader: COLORS.blueGrey[600], + facetHeaderDisabled: COLORS.blueGrey[400], facetItemSelected: COLORS.indigo[50], facetItemSelectedHover: COLORS.indigo[100], facetItemSelectedBorder: primary.light, diff --git a/server/sonar-web/tailwind.base.config.js b/server/sonar-web/tailwind.base.config.js index 48c1fafaa40..3971e725b8b 100644 --- a/server/sonar-web/tailwind.base.config.js +++ b/server/sonar-web/tailwind.base.config.js @@ -74,6 +74,7 @@ module.exports = { 6: '1.5rem', // 24px 7: '1.75rem', // 28px 8: '2rem', // 32px + 9: '2.25rem', // 36px 10: '2.5rem', // 40px 12: '3rem', // 48px 14: '3.75rem', // 60px