useDayPicker,
} from 'react-day-picker';
import { useIntl } from 'react-intl';
+import { InputSelect } from '../../sonar-aligned/components/input';
import { InteractiveIcon } from '../InteractiveIcon';
import { ChevronLeftIcon, ChevronRightIcon } from '../icons';
-import { InputSelect } from './InputSelect';
const YEARS_TO_DISPLAY = 10;
const MONTHS_IN_A_YEAR = 12;
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
+import { GroupBase, OnChangeValue } from 'react-select';
import tw from 'twin.macro';
import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
-import { InputSizeKeys } from '../../types/theme';
-import { InputSelect, LabelValueSelectOption } from './InputSelect';
+import { InputSelect, SelectProps } from '../../sonar-aligned/components/input';
-interface Props<V> {
- className?: string;
- components?: Parameters<typeof InputSelect>[0]['components'];
+type DiscreetProps<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+> = SelectProps<Option, IsMulti, Group> & {
customValue?: JSX.Element;
- isDisabled?: boolean;
- menuIsOpen?: boolean;
- onMenuClose?: () => void;
- onMenuOpen?: () => void;
- options: Array<LabelValueSelectOption<V>>;
- setValue: ({ value }: LabelValueSelectOption<V>) => void;
- size?: InputSizeKeys;
- value: V;
-}
+ setValue: (value: OnChangeValue<Option, IsMulti>) => void;
+};
-export function DiscreetSelect<V>({
- className,
- customValue,
- onMenuOpen,
- options,
- size = 'small',
- setValue,
- value,
- ...props
-}: Props<V>) {
+export function DiscreetSelect<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>({ customValue, size = 'small', setValue, ...props }: DiscreetProps<Option, IsMulti, Group>) {
return (
- <StyledSelect
- className={className}
+ <StyledSelect<Option, IsMulti, Group>
onChange={setValue}
- onMenuOpen={onMenuOpen}
- options={options}
placeholder={customValue}
size={size}
- value={options.find((item) => item.value === value)}
{...props}
/>
);
& .react-select__control--menu-is-open {
${tw`sw-border-none`};
}
-`;
+` as typeof InputSelect;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { useIntl } from 'react-intl';
-import ReactSelect, {
- ClearIndicatorProps,
- 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 { InteractiveIcon } from '../InteractiveIcon';
-import { SearchHighlighter } from '../SearchHighlighter';
-
-import { ChevronDownIcon, CloseIcon } from '../icons';
-
-export interface LabelValueSelectOption<V> {
- Icon?: ReactNode;
- label: string;
- value: V;
-}
-
-interface ExtensionProps {
- clearLabel?: string;
- size?: InputSizeKeys;
-}
-
-export type SelectProps<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>,
-> = NamedProps<Option, IsMulti, Group> & ExtensionProps;
-
-export function IconOption<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- 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}
- <SearchHighlighter>{label}</SearchHighlighter>
- </div>
- </components.Option>
- );
-}
-
-function SingleValue<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- 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 ClearIndicator<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>,
->(
- props: ClearIndicatorProps<Option, IsMulti, Group> & {
- selectProps: SelectProps<V, Option, IsMulti, Group>;
- },
-) {
- const intl = useIntl();
- const {
- selectProps: { clearLabel },
- } = props;
-
- return (
- <components.ClearIndicator {...props}>
- <InteractiveIcon
- Icon={CloseIcon}
- aria-label={clearLabel ?? intl.formatMessage({ id: 'clear' })}
- onClick={props.clearValue}
- size="small"
- />
- </components.ClearIndicator>
- );
-}
-
-function DropdownIndicator<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>,
->(props: OptionProps<Option, IsMulti, Group>) {
- return (
- <components.DropdownIndicator {...props}>
- <div className="sw-pr-2 sw-flex">
- <ChevronDownIcon />
- </div>
- </components.DropdownIndicator>
- );
-}
-
-export function InputSelect<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>,
->({ size = 'medium', className, ...props }: SelectProps<V, Option, IsMulti, Group>) {
- return (
- <ReactSelect<Option, IsMulti, Group>
- {...omit(props, 'className', 'large')}
- className={classNames('react-select', className)}
- classNamePrefix="react-select"
- classNames={{
- container: () => 'sw-relative sw-inline-block sw-align-middle',
- placeholder: () => 'sw-truncate sw-leading-4',
- menu: () => 'sw-z-dropdown-menu sw-ml-1/2 sw-mt-2',
- menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
- clearIndicator: () => 'sw-p-0',
- dropdownIndicator: () => classNames(props.isClearable && 'sw-p-0'),
- control: ({ isDisabled }) =>
- classNames(
- 'sw-box-border sw-rounded-2 sw-overflow-hidden',
- isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed',
- ),
- option: ({ isDisabled }) =>
- classNames(
- 'it__select-option sw-py-2 sw-px-3 sw-cursor-pointer',
- isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed',
- ),
- ...props.classNames,
- }}
- components={{
- ClearIndicator,
- Option: IconOption,
- SingleValue,
- DropdownIndicator,
- IndicatorSeparator: null,
- ...props.components,
- }}
- isClearable={props.isClearable ?? false}
- isSearchable={props.isSearchable ?? false}
- onMenuOpen={props.onMenuOpen}
- styles={selectStyle({ size })}
- />
- );
-}
-
-export function selectStyle<
- V,
- Option extends LabelValueSelectOption<V>,
- IsMulti extends boolean = false,
- Group extends GroupBase<Option> = GroupBase<Option>,
->({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
- const theme = themeInfo();
-
- return {
- control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
- ...base,
- color: themeContrast('inputBackground')({ theme }),
- cursor: 'pointer',
- 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],
- }),
- 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('inputPlaceholder')({ theme }),
- }),
- };
-}
*/
import classNames from 'classnames';
import { omit } from 'lodash';
-import React, { RefObject } from 'react';
+import React from 'react';
import { GroupBase, InputProps } from 'react-select';
import AsyncSelect, { AsyncProps } from 'react-select/async';
-import Select from 'react-select/dist/declarations/src/Select';
import { INPUT_SIZES } from '../../helpers';
import { Key } from '../../helpers/keyboard';
+import { SelectProps, selectStyle } from '../../sonar-aligned/components/input';
import { InputSearch } from './InputSearch';
-import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
type SearchSelectProps<
- V,
- Option extends LabelValueSelectOption<V>,
+ Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
-> = SelectProps<V, Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;
+> = SelectProps<Option, IsMulti, Group> & AsyncProps<Option, IsMulti, Group>;
export function SearchSelect<
- V,
- Option extends LabelValueSelectOption<V>,
+ Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
->({
- size = 'full',
- selectRef,
- ...props
-}: SearchSelectProps<V, Option, IsMulti, Group> & {
- selectRef?: RefObject<Select<Option, IsMulti, Group>>;
-}) {
- const styles = selectStyle<V, Option, IsMulti, Group>({ size });
+>({ size = 'full', selectRef, ...props }: SearchSelectProps<Option, IsMulti, Group>) {
+ const styles = selectStyle<Option, IsMulti, Group>({ size });
return (
<AsyncSelect<Option, IsMulti, Group>
{...omit(props, 'className', 'large')}
}
export function SearchSelectInput<
- V,
- Option extends LabelValueSelectOption<V>,
+ Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
>(props: InputProps<Option, IsMulti, Group>) {
import Select from 'react-select/dist/declarations/src/Select';
import tw from 'twin.macro';
import { PopupPlacement, PopupZLevel, themeBorder } from '../../helpers';
+import {
+ IconOption,
+ LabelValueSelectOption,
+ SelectProps,
+} from '../../sonar-aligned/components/input';
import { InputSizeKeys } from '../../types/theme';
import { DropdownToggler } from '../DropdownToggler';
import { SearchHighlighterContext } from '../SearchHighlighter';
-import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
import { SearchSelect } from './SearchSelect';
import { SearchSelectDropdownControl } from './SearchSelectDropdownControl';
Option extends LabelValueSelectOption<V>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
-> extends SelectProps<V, Option, IsMulti, Group>,
+> extends SelectProps<Option, IsMulti, Group>,
AsyncProps<Option, IsMulti, Group> {
className?: string;
controlAriaLabel?: string;
import { DiscreetSelect } from '../DiscreetSelect';
it('should render discreet select and invoke CB on value click', async () => {
- const value = 'foo';
+ const value = options[0];
const setValue = jest.fn();
const user = userEvent.setup();
expect(setValue).toHaveBeenCalled();
});
+const options = [
+ { label: 'foo-bar', value: 'foo', default: 1 },
+ {
+ label: 'bar-foo',
+ value: 'bar',
+ Icon: (
+ <span role="note" title="Icon">
+ Icon
+ </span>
+ ),
+ },
+];
+
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}
- />,
+ <DiscreetSelect options={options} setValue={jest.fn()} value={options[0]} {...props} />,
);
}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { renderWithContext } 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();
-});
-
-it('should render select input with clearable', async () => {
- const setValue = jest.fn();
- const user = userEvent.setup();
- setupWithProps({
- placeholder: 'placeholder-foo',
- onChange: setValue,
- isClearable: true,
- clearLabel: 'clear-label',
- });
- 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();
-
- // Clear button container aria-hidden by default
- expect(screen.getByRole('button', { name: 'clear-label', hidden: true })).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'clear-label', hidden: true }));
- expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
-});
-
-it('should render select input with disabled prop', () => {
- const setValue = jest.fn();
- setupWithProps({ placeholder: 'placeholder-foo', onChange: setValue, isDisabled: true });
- expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
- expect(screen.getByRole('combobox')).toBeDisabled();
-});
-
-function setupWithProps(props: Partial<FCProps<typeof InputSelect>>) {
- return renderWithContext(
- <InputSelect
- {...props}
- options={[
- { label: 'foo-bar', value: 'foo' },
- {
- label: 'bar-foo',
- value: 'bar',
- Icon: (
- <span role="note" title="Icon">
- Icon
- </span>
- ),
- },
- ]}
- />,
- );
-}
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithContext } from '../../../helpers/testUtils';
+import { LabelValueSelectOption } from '../../../sonar-aligned';
import { FCProps } from '../../../types/misc';
-import { LabelValueSelectOption } from '../InputSelect';
import { SearchSelectDropdown } from '../SearchSelectDropdown';
const defaultOptions = [
export * from './InputField';
export * from './InputMultiSelect';
export * from './InputSearch';
-export * from './InputSelect';
export * from './MultiSelectMenu';
export * from './RadioButton';
export * from './SearchSelect';
export * from './Table';
export * from './ToggleButton';
export * from './buttons';
+export * from './input';
export * from './typography';
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 classNames from 'classnames';
+import { omit } from 'lodash';
+import { useMemo } from 'react';
+import ReactSelect, { GroupBase } from 'react-select';
+import {
+ ClearIndicator,
+ DropdownIndicator,
+ IconOption,
+ SelectProps,
+ SingleValue,
+ selectStyle,
+} from './SelectCommon';
+
+export function InputSelect<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>({
+ size = 'medium',
+ className,
+ options,
+ getOptionLabel,
+ selectRef,
+ shouldSortOption = false,
+ ...props
+}: SelectProps<Option, IsMulti, Group>) {
+ const orderedOptions = useMemo(() => {
+ if (!options || options.length === 0) {
+ return options;
+ }
+
+ if (shouldSortOption) {
+ return (options as Option[]).sort((a, b) => {
+ const nameA = getOptionLabel?.(a).toUpperCase() ?? '';
+ const nameB = getOptionLabel?.(b).toUpperCase() ?? '';
+ if (nameA < nameB) {
+ return -1;
+ }
+ if (nameA > nameB) {
+ return 1;
+ }
+
+ return 0;
+ });
+ }
+
+ return options;
+ }, [shouldSortOption, getOptionLabel, options]);
+
+ return (
+ <ReactSelect<Option, IsMulti, Group>
+ {...omit(props, 'className', 'large')}
+ className={classNames('react-select', className)}
+ classNamePrefix="react-select"
+ classNames={{
+ container: () => 'sw-relative sw-inline-block sw-align-middle',
+ placeholder: () => 'sw-truncate sw-leading-4',
+ menu: () => 'sw-z-dropdown-menu sw-ml-1/2 sw-mt-2',
+ menuList: () => 'sw-overflow-y-auto sw-py-2 sw-max-h-[12.25rem]',
+ clearIndicator: () => 'sw-p-0',
+ dropdownIndicator: () => classNames(props.isClearable && 'sw-p-0'),
+ control: ({ isDisabled }) =>
+ classNames(
+ 'sw-box-border sw-rounded-2 sw-overflow-hidden',
+ isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed',
+ ),
+ option: ({ isDisabled }) =>
+ classNames(
+ 'it__select-option sw-py-2 sw-px-3 sw-cursor-pointer',
+ isDisabled && 'sw-pointer-events-none sw-cursor-not-allowed',
+ ),
+ ...props.classNames,
+ }}
+ components={{
+ ClearIndicator,
+ Option: IconOption,
+ SingleValue,
+ DropdownIndicator,
+ IndicatorSeparator: null,
+ ...props.components,
+ }}
+ getOptionLabel={getOptionLabel}
+ isClearable={props.isClearable ?? false}
+ isSearchable={props.isSearchable ?? false}
+ onMenuOpen={props.onMenuOpen}
+ options={orderedOptions}
+ ref={selectRef}
+ styles={selectStyle({ size })}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { RefObject } from 'react';
+import { useIntl } from 'react-intl';
+import {
+ ClearIndicatorProps,
+ GroupBase,
+ Props as NamedProps,
+ OptionProps,
+ StylesConfig,
+ components,
+} from 'react-select';
+import Select from 'react-select/dist/declarations/src/Select';
+import { InteractiveIcon } from '../../../components/InteractiveIcon';
+import { SearchHighlighter } from '../../../components/SearchHighlighter';
+import { ChevronDownIcon, CloseIcon } from '../../../components/icons';
+import { INPUT_SIZES } from '../../../helpers';
+import { themeBorder, themeColor, themeContrast } from '../../../helpers/theme';
+import { InputSizeKeys } from '../../../types/theme';
+
+export interface ExtensionProps<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+> {
+ clearLabel?: string;
+ selectRef?: RefObject<Select<Option, IsMulti, Group>>;
+ shouldSortOption?: boolean;
+ size?: InputSizeKeys;
+}
+
+export type SelectProps<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+> = NamedProps<Option, IsMulti, Group> & ExtensionProps<Option, IsMulti, Group>;
+
+export function IconOption<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>(props: OptionProps<Option, IsMulti, Group>) {
+ const { label, isSelected } = props;
+ const { Icon } = props.data as { Icon: JSX.Element };
+
+ return (
+ <components.Option {...props}>
+ <div aria-selected={isSelected} className="sw-flex sw-items-center sw-gap-1" role="option">
+ {Icon}
+ <SearchHighlighter>{label}</SearchHighlighter>
+ </div>
+ </components.Option>
+ );
+}
+
+export function SingleValue<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>(props: OptionProps<Option, IsMulti, Group>) {
+ const label = props.selectProps.getOptionLabel(props.data);
+ const { Icon } = props.data as { Icon: JSX.Element };
+
+ return (
+ <components.SingleValue {...props}>
+ <div className="sw-flex sw-items-center sw-gap-1">
+ {Icon}
+ {label}
+ </div>
+ </components.SingleValue>
+ );
+}
+
+export function ClearIndicator<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>(
+ props: ClearIndicatorProps<Option, IsMulti, Group> & {
+ selectProps: SelectProps<Option, IsMulti, Group>;
+ },
+) {
+ const intl = useIntl();
+ const {
+ selectProps: { clearLabel },
+ } = props;
+
+ return (
+ <components.ClearIndicator {...props}>
+ <InteractiveIcon
+ Icon={CloseIcon}
+ aria-label={clearLabel ?? intl.formatMessage({ id: 'clear' })}
+ onClick={props.clearValue}
+ size="small"
+ />
+ </components.ClearIndicator>
+ );
+}
+
+export function DropdownIndicator<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>(props: OptionProps<Option, IsMulti, Group>) {
+ return (
+ <components.DropdownIndicator {...props}>
+ <div className="sw-pr-2 sw-flex">
+ <ChevronDownIcon />
+ </div>
+ </components.DropdownIndicator>
+ );
+}
+
+export function selectStyle<
+ Option,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>,
+>({ size }: { size: InputSizeKeys }): StylesConfig<Option, IsMulti, Group> {
+ const theme = themeInfo();
+
+ return {
+ control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
+ ...base,
+ color: themeContrast('inputBackground')({ theme }),
+ cursor: 'pointer',
+ 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],
+ }),
+ 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('inputPlaceholder')({ theme }),
+ }),
+ };
+}
+
+export interface LabelValueSelectOption<V = string> {
+ Icon?: React.ReactNode;
+ label: string;
+ value: V;
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { renderWithContext } 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();
+});
+
+it('should render select input with clearable', async () => {
+ const setValue = jest.fn();
+ const user = userEvent.setup();
+ setupWithProps({
+ placeholder: 'placeholder-foo',
+ onChange: setValue,
+ isClearable: true,
+ clearLabel: 'clear-label',
+ });
+ 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();
+
+ // Clear button container aria-hidden by default
+ expect(screen.getByRole('button', { name: 'clear-label', hidden: true })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'clear-label', hidden: true }));
+ expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
+});
+
+it('should render select input with disabled prop', () => {
+ const setValue = jest.fn();
+ setupWithProps({ placeholder: 'placeholder-foo', onChange: setValue, isDisabled: true });
+ expect(screen.getByText('placeholder-foo')).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toBeDisabled();
+});
+
+it('should render the select options with sorting when shouldSortOption is true and getOptionLabel passed', async () => {
+ const { user } = setupWithProps({
+ shouldSortOption: true,
+ getOptionLabel: (o: { label: string }) => o.label,
+ });
+ await user.click(screen.getByRole('combobox'));
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(2);
+ expect(options[0]).toHaveTextContent('bar-foo');
+ expect(options[1]).toHaveTextContent('foo-bar');
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSelect>>) {
+ return renderWithContext(
+ <InputSelect
+ {...props}
+ options={[
+ { label: 'foo-bar', value: 'foo' },
+ {
+ label: 'bar-foo',
+ value: 'bar',
+ Icon: (
+ <span role="note" title="Icon">
+ Icon
+ </span>
+ ),
+ },
+ ]}
+ />,
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+
+export * from './InputSelect';
+export * from './SelectCommon';
export default function StatusFilter(props: Readonly<StatusFilterProps>) {
const { id, value, onChange } = props;
- const options: LabelValueSelectOption<string>[] = [
+ const options: LabelValueSelectOption[] = [
{ value: STATUSES.ALL, label: translate('background_task.status.ALL') },
{
value: STATUSES.ALL_EXCEPT_PENDING,
];
const handleChange = React.useCallback(
- ({ value }: LabelValueSelectOption<string>) => {
+ ({ value }: LabelValueSelectOption) => {
onChange(value);
},
[onChange],
}
export default class TypesFilter extends React.PureComponent<Props> {
- handleChange = ({ value }: LabelValueSelectOption<string>) => {
+ handleChange = ({ value }: LabelValueSelectOption) => {
this.props.onChange(value);
};
};
});
- const allOptions: LabelValueSelectOption<string>[] = [
+ const allOptions: LabelValueSelectOption[] = [
{ value: ALL_TYPES, label: translate('background_task.type.ALL') },
...options,
];
);
const StatusField = React.useMemo(() => {
- const statusesOptions = RULE_STATUSES.map((status) => ({
+ const statusesOptions = RULE_STATUSES.map((status: Status) => ({
label: translate('rules.status', status),
value: status,
}));
function transformToOptions(
projects: AzureProject[],
repositories?: Dict<AzureRepository[]>,
-): Array<GroupBase<LabelValueSelectOption<string>>> {
+): Array<GroupBase<LabelValueSelectOption>> {
return projects.map(({ name: projectName }) => ({
label: projectName,
options:
}));
}
-function transformToOption({ name }: AzureRepository): LabelValueSelectOption<string> {
+function transformToOption({ name }: AzureRepository): LabelValueSelectOption {
return { value: name, label: name };
}
);
}
-function transformToOption({
- name,
- slug,
-}: BitbucketCloudRepository): LabelValueSelectOption<string> {
+function transformToOption({ name, slug }: BitbucketCloudRepository): LabelValueSelectOption {
return { value: slug, label: name };
}
function transformToOption({
key,
name,
-}: GithubOrganization | GithubRepository): LabelValueSelectOption<string> {
+}: GithubOrganization | GithubRepository): LabelValueSelectOption {
return { value: key, label: name };
}
);
}
-function transformToOption({ id, name }: GitlabProject): LabelValueSelectOption<string> {
+function transformToOption({ id, name }: GitlabProject): LabelValueSelectOption {
return { value: id, label: name };
}
const UNASSIGNED = { value: '', label: translate('unassigned') };
export interface AssigneeSelectProps {
- assignee?: SingleValue<LabelValueSelectOption<string>>;
+ assignee?: SingleValue<LabelValueSelectOption>;
className?: string;
issues: Issue[];
- onAssigneeSelect: (assignee: SingleValue<LabelValueSelectOption<string>>) => void;
+ onAssigneeSelect: (assignee: SingleValue<LabelValueSelectOption>) => void;
inputId: string;
}
interface FormFields {
addTags?: Array<string>;
- assignee?: SingleValue<LabelValueSelectOption<string>>;
+ assignee?: SingleValue<LabelValueSelectOption>;
comment?: string;
notifications?: boolean;
removeTags?: Array<string>;
return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
};
- handleAssigneeSelect = (assignee: SingleValue<LabelValueSelectOption<string>>) => {
+ handleAssigneeSelect = (assignee: SingleValue<LabelValueSelectOption>) => {
this.setState({ assignee });
};
}
};
- handlePermissionTemplateChange = ({ value }: LabelValueSelectOption<string>) => {
+ handlePermissionTemplateChange = ({ value }: LabelValueSelectOption) => {
this.setState({ permissionTemplate: value });
};
const eventTypes = isApp
? Object.values(ApplicationAnalysisEventCategory)
: Object.values(ProjectAnalysisEventCategory);
- const options: LabelValueSelectOption<string>[] = eventTypes.map((category) => ({
+ const options: LabelValueSelectOption[] = eventTypes.map((category) => ({
label: translate('event.category', category),
value: category,
}));
aria-label={translate('project_activity.filter_events')}
className="sw-mr-8 sw-body-sm sw-w-abs-200"
isClearable
- onChange={(data: LabelValueSelectOption<string>) => handleCategoryChange(data)}
+ onChange={(data: LabelValueSelectOption) => handleCategoryChange(data)}
options={options}
placeholder={translate('project_activity.filter_events')}
size="full"
<InputSelect
aria-labelledby="aria-projects-perspective"
className="sw-mr-4 sw-body-sm"
- onChange={(data: LabelValueSelectOption<string>) => this.handleChange(data)}
+ onChange={(data: LabelValueSelectOption) => this.handleChange(data)}
options={options}
placeholder={translate('project_activity.filter_events')}
size="small"
}
};
- handlePermissionTemplateChange = ({ value }: LabelValueSelectOption<string>) => {
+ handlePermissionTemplateChange = ({ value }: LabelValueSelectOption) => {
this.setState({ permissionTemplate: value });
};
noOptionsMessage={() => translate('no_results')}
onChange={props.onSelection}
loadOptions={props.handleSearch}
- getOptionValue={({ value }) => (isUser(value) ? value.login : value.name)}
+ getOptionValue={({ value }: LabelValueSelectOption<UserBase | UserGroup>) =>
+ isUser(value) ? value.login : value.name
+ }
controlLabel={renderedSelection}
components={{
Option,