]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19163 Issues page: create filter components
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Thu, 11 May 2023 14:28:47 +0000 (16:28 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 11 May 2023 20:03:13 +0000 (20:03 +0000)
server/sonar-web/design-system/src/components/FacetBox.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/FacetItem.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/ToggleButton.tsx
server/sonar-web/design-system/src/components/__tests__/FacetBox-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/FacetItem-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx
server/sonar-web/design-system/src/components/buttons.tsx
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/tailwind.base.config.js

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 (file)
index 0000000..db57773
--- /dev/null
@@ -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 (
+    <Accordion className={classNames(className, { open })} inner={inner} role="listitem">
+      <Header>
+        <ChevronAndTitle
+          aria-controls={`${id}-panel`}
+          aria-disabled={!expandable}
+          aria-expanded={open}
+          aria-label={ariaLabel ?? name}
+          expandable={expandable}
+          id={`${id}-header`}
+          onClick={() => {
+            if (!disabled) {
+              onClick?.(!open);
+            }
+          }}
+        >
+          {expandable && <OpenCloseIndicator aria-hidden={true} open={open} />}
+
+          <HeaderTitle disabled={disabled}>{name}</HeaderTitle>
+        </ChevronAndTitle>
+
+        {<DeferredSpinner loading={loading} />}
+
+        {counter > 0 && (
+          <BadgeAndIcons>
+            <Badge title={countLabel} variant="counter">
+              {counter}
+            </Badge>
+
+            {clearable && (
+              <Tooltip overlay={clearIconLabel}>
+                <ClearIcon
+                  Icon={CloseIcon}
+                  aria-label={clearIconLabel ?? ''}
+                  onClick={onClear}
+                  size="small"
+                />
+              </Tooltip>
+            )}
+          </BadgeAndIcons>
+        )}
+      </Header>
+
+      {open && (
+        <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="region">
+          {children}
+        </div>
+      )}
+    </Accordion>
+  );
+}
+
+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 (file)
index 0000000..0d318fe
--- /dev/null
@@ -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<ButtonProps, 'name' | 'onClick'> & {
+  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<HTMLButtonElement>) => {
+    event.preventDefault();
+
+    onClick(value, event.ctrlKey || event.metaKey);
+  };
+
+  return (
+    <StyledButton
+      active={active}
+      className={className}
+      data-facet={value}
+      disabled={disabled}
+      icon={icon}
+      onClick={handleClick}
+      role="listitem"
+      title={tooltip}
+    >
+      <span className="container">
+        <span className="name">{name}</span>
+        <span className="stat">{stat}</span>
+      </span>
+    </StyledButton>
+  );
+}
+
+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;
+    }
+  }
+`;
index 17383161cbfcce425b519322404d1cdf722451e0..4ae54df92cbf1352065d5b8f54235be51ca4196b 100644 (file)
@@ -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 (file)
index 0000000..88dd949
--- /dev/null
@@ -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<FacetBoxProps> = {}) {
+  return render(
+    <FacetBox name="Test FacetBox" {...props}>
+      {children}
+    </FacetBox>
+  );
+}
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 (file)
index 0000000..12703d3
--- /dev/null
@@ -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<FacetItemProps> = {}) {
+  return render(<FacetItem name="Test facet item" onClick={jest.fn()} value="Value" {...props} />);
+}
index d42cd875eee467aa931cd018ae9032598a55bfe0..c691211c7ead1f92980f80657f94da365e2c0d97 100644 (file)
@@ -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);
index 18030488b64365eaa7bfaa19c3e8e69061dcc00b..28650a777c0cd56d60fb867934f033d90fca16d6 100644 (file)
@@ -38,7 +38,7 @@ export interface ButtonProps extends AllowedButtonAttributes {
   disabled?: boolean;
   icon?: React.ReactNode;
   innerRef?: React.Ref<HTMLButtonElement>;
-  onClick?: VoidFunction;
+  onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
 
   preventDefault?: boolean;
   reloadDocument?: LinkProps['reloadDocument'];
@@ -61,7 +61,7 @@ class Button extends React.PureComponent<ButtonProps> {
     }
 
     if (onClick && !disabled) {
-      onClick();
+      onClick(event);
     }
   };
 
index a4af02dcab0d061bc314a20c42505d1f99dafc48..37ee85e89191ae990a8939d2e87fbed8ce1e0a02 100644 (file)
@@ -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';
index 3683139b845b040b9bd98509da2d5788d2aca1ab..6a2f8bd5e9597da34e55dcd9058f57922677fdef 100644 (file)
@@ -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,
index 48c1fafaa406de0adb6f1504645419f472f197ad..3971e725b8bfc803ceb3051f892f7404c122afe9 100644 (file)
@@ -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