]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19172 Issues page: create a "discreet select" component for the issue details...
authorKevin Silva <kevin.silva@sonarsource.com>
Fri, 5 May 2023 14:26:46 +0000 (16:26 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 10 May 2023 20:05:28 +0000 (20:05 +0000)
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/Checkbox.tsx
server/sonar-web/design-system/src/components/DiscreetSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/DropdownMenu.tsx
server/sonar-web/design-system/src/components/InputSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/RadioButton.tsx
server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/src/main/js/components/issue/components/IssueSeverity.tsx
server/sonar-web/yarn.lock

index 8e280e6794eb7a9aa7127e0d5e8abc8b9bd0b0d0..4e07b7d40ce4efa1c47aabddc0baf4d03ed126bf 100644 (file)
@@ -59,6 +59,7 @@
     "react-helmet-async": "1.3.0",
     "react-intl": "6.2.5",
     "react-router-dom": "6.10.0",
+    "react-select": "5.7.2",
     "tailwindcss": "3.3.1"
   },
   "babelMacros": {
index 2968443b597f8238ef253e20f647fa888f3e2165..648fd6af48c215acbaac6a3933e101e0d415b46d 100644 (file)
@@ -41,7 +41,7 @@ interface Props {
   title?: string;
 }
 
-export default function Checkbox({
+export function Checkbox({
   checked,
   disabled,
   children,
diff --git a/server/sonar-web/design-system/src/components/DiscreetSelect.tsx b/server/sonar-web/design-system/src/components/DiscreetSelect.tsx
new file mode 100644 (file)
index 0000000..7d68321
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * 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 tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys } from '../types/theme';
+import { InputSelect, LabelValueSelectOption } from './InputSelect';
+
+interface Props {
+  className?: string;
+  customValue?: JSX.Element;
+  options: LabelValueSelectOption[];
+  setValue: ({ value }: LabelValueSelectOption) => void;
+  size?: InputSizeKeys;
+  value: string;
+}
+
+export function DiscreetSelect({
+  className,
+  customValue,
+  options,
+  size = 'small',
+  setValue,
+  value,
+  ...props
+}: Props) {
+  return (
+    <StyledSelect
+      className={className}
+      onChange={setValue}
+      options={options}
+      placeholder={customValue}
+      size={size}
+      value={options.find((item) => item.value === value)}
+      {...props}
+    />
+  );
+}
+
+const StyledSelect = styled(InputSelect)`
+  & {
+    width: inherit !important;
+  }
+
+  & .react-select__dropdown-indicator {
+    ${tw`sw-p-0 sw-py-1`};
+  }
+
+  & .react-select__value-container {
+    ${tw`sw-p-0`};
+  }
+
+  & .react-select__menu {
+    margin: 0;
+  }
+
+  & .react-select__control {
+    height: auto;
+    color: ${themeContrast('discreetBackground')};
+    background: none;
+    outline: inherit;
+    box-shadow: none;
+
+    ${tw`sw-border-none`};
+    ${tw`sw-p-0`};
+    ${tw`sw-cursor-pointer`};
+    ${tw`sw-flex sw-items-center`};
+    ${tw`sw-body-sm`};
+    ${tw`sw-select-none`};
+
+    &:hover {
+      ${tw`sw-border-none`};
+      outline: none;
+      color: ${themeColor('discreetButtonHover')};
+      border-color: inherit;
+      box-shadow: none;
+
+      & .react-select__single-value,
+      & .react-select__dropdown-indicator,
+      & .react-select__placeholder {
+        color: ${themeColor('discreetButtonHover')};
+      }
+    }
+
+    &:focus {
+      ${tw`sw-rounded-1`};
+      color: ${themeColor('discreetButtonHover')};
+      background: ${themeColor('discreetBackground')};
+      outline: ${themeBorder('focus', 'discreetFocusBorder')};
+      outline: none;
+      border-color: inherit;
+      box-shadow: none;
+    }
+  }
+
+  & .react-select__control--is-focused,
+  & .react-select__control--menu-is-open {
+    ${tw`sw-border-none`};
+    outline: none;
+  }
+`;
index d16011785b92ee4b0153217d5cc855f8775a356b..b9e430d762379320fa19c10aac6993a7979fefa3 100644 (file)
@@ -26,12 +26,12 @@ import { INPUT_SIZES } from '../helpers/constants';
 import { translate } from '../helpers/l10n';
 import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { InputSizeKeys, ThemedProps } from '../types/theme';
-import Checkbox from './Checkbox';
-import { ClipboardBase } from './clipboard';
+import { Checkbox } from './Checkbox';
 import { BaseLink, LinkProps } from './Link';
 import NavLink from './NavLink';
-import RadioButton from './RadioButton';
+import { RadioButton } from './RadioButton';
 import Tooltip from './Tooltip';
+import { ClipboardBase } from './clipboard';
 
 interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> {
   children?: React.ReactNode;
diff --git a/server/sonar-web/design-system/src/components/InputSelect.tsx b/server/sonar-web/design-system/src/components/InputSelect.tsx
new file mode 100644 (file)
index 0000000..2563d3c
--- /dev/null
@@ -0,0 +1,191 @@
+/*
+ * 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 as themeInfo } from '@emotion/react';
+import classNames from 'classnames';
+import { omit } from 'lodash';
+import { ReactNode } from 'react';
+import ReactSelect, {
+  GroupBase,
+  Props as NamedProps,
+  OptionProps,
+  StylesConfig,
+  components,
+} from 'react-select';
+import { INPUT_SIZES } from '../helpers';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
+import { InputSizeKeys } from '../types/theme';
+import { ChevronDownIcon } from './icons';
+
+export interface LabelValueSelectOption<V = string> {
+  Icon?: ReactNode;
+  label: string;
+  value: V;
+}
+
+interface StyleExtensionProps {
+  size?: InputSizeKeys;
+}
+
+type SelectProps<
+  Option = LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+> = NamedProps<Option, IsMulti, Group> & StyleExtensionProps;
+
+function IconOption<
+  Option extends LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  const {
+    data: { label, Icon },
+  } = props;
+
+  return (
+    <components.Option {...props}>
+      <div className="sw-flex sw-items-center sw-gap-1">
+        {Icon}
+        {label}
+      </div>
+    </components.Option>
+  );
+}
+
+function SingleValue<
+  Option extends LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  const {
+    data: { label, Icon },
+  } = props;
+
+  return (
+    <components.SingleValue {...props}>
+      <div className="sw-flex sw-items-center sw-gap-1">
+        {Icon}
+        {label}
+      </div>
+    </components.SingleValue>
+  );
+}
+
+function IndicatorsContainer<
+  Option extends LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+  return (
+    <components.IndicatorsContainer {...props}>
+      <div className="sw-pr-2">
+        <ChevronDownIcon />
+      </div>
+    </components.IndicatorsContainer>
+  );
+}
+
+export function InputSelect<
+  Option extends LabelValueSelectOption = LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>({ size = 'medium', ...props }: SelectProps<Option, IsMulti, Group>) {
+  return (
+    <ReactSelect<Option, IsMulti, Group>
+      {...omit(props, 'className', 'large')}
+      className={classNames('react-select', props.className)}
+      classNamePrefix="react-select"
+      classNames={{
+        container: () => 'sw-relative sw-inline-block sw-align-middle',
+        placeholder: () => 'sw-truncate sw-leading-4',
+        menu: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
+        control: ({ isDisabled }) =>
+          classNames(
+            'sw-absolut sw-box-border sw-rounded-2 sw-mt-1 sw-overflow-hidden sw-z-dropdown-menu',
+            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+          ),
+        option: ({ isDisabled }) =>
+          classNames(
+            'sw-py-2 sw-px-3 sw-cursor-pointer',
+            isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed'
+          ),
+      }}
+      components={{
+        ...props.components,
+        Option: IconOption,
+        SingleValue,
+        IndicatorsContainer,
+        IndicatorSeparator: null,
+      }}
+      isSearchable={props.isSearchable ?? false}
+      styles={selectStyle<Option, IsMulti, Group>({ size })}
+    />
+  );
+}
+
+export function selectStyle<
+  Option = LabelValueSelectOption,
+  IsMulti extends boolean = boolean,
+  Group extends GroupBase<Option> = GroupBase<Option>
+>({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
+  const theme = themeInfo();
+
+  return {
+    container: (base) => ({
+      ...base,
+      width: INPUT_SIZES[size],
+    }),
+    control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
+      ...base,
+      color: themeContrast('inputBackground')({ theme }),
+      background: themeColor('inputBackground')({ theme }),
+      transition: 'border 0.2s ease, outline 0.2s ease',
+      outline: isFocused && !menuIsOpen ? themeBorder('focus', 'inputFocus')({ theme }) : 'none',
+      ...(isDisabled && {
+        color: themeContrast('inputDisabled')({ theme }),
+        background: themeColor('inputDisabled')({ theme }),
+        border: themeBorder('default', 'inputDisabledBorder')({ theme }),
+        outline: 'none',
+      }),
+      ...(isFocused && {
+        border: themeBorder('default', 'inputBorder')({ theme }),
+      }),
+    }),
+    menu: (base) => ({
+      ...base,
+      width: INPUT_SIZES[size],
+      zIndex: 101,
+    }),
+    option: (base, { isFocused, isSelected }) => ({
+      ...base,
+      ...((isSelected || isFocused) && {
+        background: themeColor('selectOptionSelected')({ theme }),
+        color: themeContrast('primaryLight')({ theme }),
+      }),
+    }),
+    singleValue: (base) => ({
+      ...base,
+      color: themeContrast('primaryLight')({ theme }),
+    }),
+    placeholder: (base) => ({
+      ...base,
+      color: themeContrast('primaryLight')({ theme }),
+    }),
+  };
+}
index 89858e7357460be47de024f974432390f82ab719..a49eb04eb18d01bcdd82f40f0f901d8cb909cd1a 100644 (file)
@@ -37,7 +37,7 @@ interface Props extends AllowedRadioButtonAttributes {
   value: string;
 }
 
-export default function RadioButton({
+export function RadioButton({
   checked,
   children,
   className,
diff --git a/server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DiscreetSelect-test.tsx
new file mode 100644 (file)
index 0000000..fea6a4c
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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 { FCProps } from '../../types/misc';
+import { DiscreetSelect } from '../DiscreetSelect';
+
+it('should render discreet select and invoke CB on value click', async () => {
+  const value = 'foo';
+  const setValue = jest.fn();
+
+  const user = userEvent.setup();
+  setupWithProps({ setValue, value });
+  await user.click(screen.getByRole('combobox'));
+  expect(screen.getByText(/option foo-bar selected/)).toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+  await user.click(screen.getByText('bar-foo'));
+  expect(setValue).toHaveBeenCalled();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof DiscreetSelect>>) {
+  return render(
+    <DiscreetSelect
+      options={[
+        { label: 'foo-bar', value: 'foo' },
+        {
+          label: 'bar-foo',
+          value: 'bar',
+          Icon: (
+            <span role="note" title="Icon">
+              Icon
+            </span>
+          ),
+        },
+      ]}
+      setValue={jest.fn()}
+      value="foo"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputSelect-test.tsx
new file mode 100644 (file)
index 0000000..8d83594
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * 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 { FCProps } from '../../types/misc';
+import { InputSelect } from '../InputSelect';
+
+it('should render select input and be able to click and change', async () => {
+  const setValue = jest.fn();
+  const user = userEvent.setup();
+  setupWithProps({ placeholder: 'placeholder-foo', onChange: setValue });
+  expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
+  await user.click(screen.getByRole('combobox'));
+  expect(screen.getByText(/option foo-bar focused/)).toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+  await user.click(screen.getByText('bar-foo'));
+  expect(setValue).toHaveBeenCalled();
+  expect(screen.queryByText('placeholder-foo')).not.toBeInTheDocument();
+  expect(screen.getByRole('note', { name: 'Icon' })).toBeInTheDocument();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSelect>>) {
+  return render(
+    <InputSelect
+      {...props}
+      options={[
+        { label: 'foo-bar', value: 'foo' },
+        {
+          label: 'bar-foo',
+          value: 'bar',
+          Icon: (
+            <span role="note" title="Icon">
+              Icon
+            </span>
+          ),
+        },
+      ]}
+    />
+  );
+}
index 199982cd38176ddecb7ac95f65e4cb43948a929c..c5e19041f0ebae45cd7126433f9e0d7683944eae 100644 (file)
@@ -25,6 +25,7 @@ export { BarChart } from './BarChart';
 export * from './Card';
 export * from './CoverageIndicator';
 export { DeferredSpinner } from './DeferredSpinner';
+export * from './DiscreetSelect';
 export { Dropdown } from './Dropdown';
 export * from './DropdownMenu';
 export { DropdownToggler } from './DropdownToggler';
@@ -34,6 +35,7 @@ export { FlagMessage } from './FlagMessage';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
 export { InputSearch } from './InputSearch';
+export * from './InputSelect';
 export * from './InteractiveIcon';
 export * from './Link';
 export { StandoutLink as Link } from './Link';
index 275ebdc96c43bd2ee4808ab7a4752617ba87f25b..3c7fda960a493a230995b1207e3b3d83a0338b5a 100644 (file)
@@ -19,8 +19,8 @@
  */
 import * as React from 'react';
 import { setIssueSeverity } from '../../../api/issues';
-import { ButtonLink } from '../../../components/controls/buttons';
 import Toggler from '../../../components/controls/Toggler';
+import { ButtonLink } from '../../../components/controls/buttons';
 import DropdownIcon from '../../../components/icons/DropdownIcon';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { IssueResponse } from '../../../types/issues';
index 9fa9de6be630af9850e0a6f2c6d067901860af09..fc4247f452ca4010f01abc16d16a36e7941c3226 100644 (file)
@@ -6159,6 +6159,7 @@ __metadata:
     react-helmet-async: 1.3.0
     react-intl: 6.2.5
     react-router-dom: 6.10.0
+    react-select: 5.7.2
     tailwindcss: 3.3.1
   languageName: unknown
   linkType: soft