interface Props {
allowNewElements?: boolean;
- clearIconAriaLabel: string;
createElementLabel: string;
headerLabel: string;
noResultsLabel: string;
export function TagsSelector(props: Props) {
const {
allowNewElements,
- clearIconAriaLabel,
createElementLabel,
headerLabel,
noResultsLabel,
return (
<MultiSelectMenu
allowNewElements={allowNewElements}
- clearIconAriaLabel={clearIconAriaLabel}
createElementLabel={createElementLabel}
elements={tags}
headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
const overlay = (
<TagsSelector
- clearIconAriaLabel="clear"
createElementLabel="create new tag"
headerLabel="edit tags"
noResultsLabel="no results"
import classNames from 'classnames';
import { debounce } from 'lodash';
import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react';
+import { useIntl } from 'react-intl';
import tw, { theme } from 'twin.macro';
import { DEBOUNCE_DELAY, INPUT_SIZES } from '../../helpers/constants';
import { Key } from '../../helpers/keyboard';
interface Props {
autoFocus?: boolean;
className?: string;
- clearIconAriaLabel: string;
id?: string;
innerRef?: React.RefCallback<HTMLInputElement>;
loading?: boolean;
placeholder?: string;
searchInputAriaLabel?: string;
size?: InputSizeKeys;
- tooShortText?: string;
value?: string;
}
maxLength = DEFAULT_MAX_LENGTH,
size = 'medium',
value: parentValue,
- tooShortText,
searchInputAriaLabel,
- clearIconAriaLabel,
- children,
}: PropsWithChildren<Props>) {
+ const intl = useIntl();
const input = useRef<null | HTMLElement>(null);
const [value, setValue] = useState(parentValue ?? '');
- const debouncedOnChange = useMemo(() => debounce(onChange, DEBOUNCE_DELAY), [onChange]);
+ const [dirty, setDirty] = useState(false);
+ const debouncedOnChange = useMemo(
+ () =>
+ debounce((val: string) => {
+ onChange(val);
+ setDirty(false);
+ }, DEBOUNCE_DELAY),
+ [onChange]
+ );
const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
const inputClassName = classNames('js-input-search', {
'sw-pr-10': value.length > 0,
});
- /*
- * ParentValue is useful as an initial value for a page load
- * And when the parent component wants to empty the search (facet search)
- * After that the input value is controlled by this component
- */
useEffect(() => {
- if (parentValue === '' || (parentValue !== undefined && value === '')) {
+ if (parentValue !== undefined && !dirty) {
setValue(parentValue);
}
}, [parentValue]); // eslint-disable-line
const handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const eventValue = event.currentTarget.value;
setValue(eventValue);
+ setDirty(true);
changeValue(eventValue);
};
id={id}
onMouseDown={onMouseDown}
style={{ '--inputSize': INPUT_SIZES[size] }}
- title={tooShort && tooShortText && isDefined(minLength) ? tooShortText : ''}
+ title={
+ tooShort && isDefined(minLength)
+ ? intl.formatMessage({ id: 'select2.tooShort' }, { 0: minLength })
+ : ''
+ }
>
<StyledInputWrapper className="sw-flex sw-items-center">
- {children ?? (
- <input
- aria-label={searchInputAriaLabel ?? placeholder}
- autoComplete="off"
- className={inputClassName}
- maxLength={maxLength}
- onBlur={onBlur}
- onChange={handleInputChange}
- onFocus={onFocus}
- onKeyDown={handleInputKeyDown}
- placeholder={placeholder}
- ref={ref}
- role="searchbox"
- type="search"
- value={value}
- />
- )}
+ <input
+ aria-label={searchInputAriaLabel ?? placeholder}
+ autoComplete="off"
+ className={inputClassName}
+ maxLength={maxLength}
+ onBlur={onBlur}
+ onChange={handleInputChange}
+ onFocus={onFocus}
+ onKeyDown={handleInputKeyDown}
+ placeholder={placeholder}
+ ref={ref}
+ role="searchbox"
+ type="search"
+ value={value}
+ />
<Spinner className="sw-z-normal" loading={loading ?? false}>
<StyledSearchIcon />
</Spinner>
{value && (
<StyledInteractiveIcon
Icon={CloseIcon}
- aria-label={clearIconAriaLabel}
+ aria-label={intl.formatMessage({ id: 'clear' })}
className="it__search-box-clear"
onClick={handleClearClick}
size="small"
/>
)}
- {tooShort && tooShortText && isDefined(minLength) && (
+ {tooShort && isDefined(minLength) && (
<StyledNote className="sw-ml-1" role="note">
- {tooShortText}
+ {intl.formatMessage({ id: 'select2.tooShort' }, { 0: minLength })}
</StyledNote>
)}
</StyledInputWrapper>
}
`;
-const StyledSearchIcon = styled(SearchIcon)`
+export const StyledSearchIcon = styled(SearchIcon)`
color: ${themeColor('inputBorder')};
top: calc((${theme('height.control')} - ${theme('spacing.4')}) / 2);
${tw`sw-right-2`}
`;
-const StyledNote = styled.span`
+export const StyledNote = styled.span`
color: ${themeColor('inputPlaceholder')};
top: calc(1px + ${theme('inset.2')});
interface Props {
allowNewElements?: boolean;
allowSelection?: boolean;
- clearIconAriaLabel: string;
createElementLabel: string;
elements: string[];
footerNode?: React.ReactNode;
headerNode = '',
footerNode = '',
inputId,
- clearIconAriaLabel,
noResultsLabel,
searchInputAriaLabel,
} = this.props;
<InputSearch
autoFocus
className="sw-mt-1"
- clearIconAriaLabel={clearIconAriaLabel}
id={inputId}
loading={this.state.loading}
onChange={this.handleSearchChange}
import classNames from 'classnames';
import { omit } from 'lodash';
import React, { RefObject } from 'react';
-import { useIntl } from 'react-intl';
import { GroupBase, InputProps, components } 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 { InputSearch } from './InputSearch';
import { LabelValueSelectOption, SelectProps, selectStyle } from './InputSelect';
+import { SearchSelectControlledInput } from './SearchSelectControlledInput';
type SearchSelectProps<
V,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: InputProps<Option, IsMulti, Group>) {
const {
- selectProps: { clearIconLabel, placeholder, isLoading, inputValue, minLength, tooShortText },
+ selectProps: { placeholder, isLoading, inputValue, minLength },
} = props;
- const intl = useIntl();
-
const onChange = (v: string, prevValue = '') => {
props.selectProps.onInputChange(v, { action: 'input-change', prevInputValue: prevValue });
};
};
return (
- <InputSearch
- clearIconAriaLabel={clearIconLabel ?? intl.formatMessage({ id: 'clear' })}
+ <SearchSelectControlledInput
loading={isLoading && inputValue.length >= (minLength ?? 0)}
minLength={minLength}
onChange={onChange}
size="full"
- tooShortText={tooShortText}
value={inputValue}
>
<components.Input
placeholder={placeholder as string}
style={{}}
/>
- </InputSearch>
+ </SearchSelectControlledInput>
);
}
--- /dev/null
+/*
+ * 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 React, { PropsWithChildren } from 'react';
+import { useIntl } from 'react-intl';
+import { INPUT_SIZES } from '../../helpers/constants';
+import { isDefined } from '../../helpers/types';
+import { InputSizeKeys } from '../../types/theme';
+import { Spinner } from '../Spinner';
+import { CloseIcon } from '../icons/CloseIcon';
+import {
+ InputSearchWrapper,
+ StyledInputWrapper,
+ StyledInteractiveIcon,
+ StyledNote,
+ StyledSearchIcon,
+} from './InputSearch';
+
+interface Props {
+ className?: string;
+ id?: string;
+ loading?: boolean;
+ minLength?: number;
+ onChange: (value: string) => void;
+ onMouseDown?: React.MouseEventHandler<HTMLInputElement>;
+ size?: InputSizeKeys;
+ value: string;
+}
+
+export function SearchSelectControlledInput({
+ id,
+ className,
+ onChange,
+ onMouseDown,
+ loading,
+ minLength,
+ size = 'medium',
+ value,
+ children,
+}: PropsWithChildren<Props>) {
+ const intl = useIntl();
+ const tooShort = isDefined(minLength) && value.length > 0 && value.length < minLength;
+
+ return (
+ <InputSearchWrapper
+ className={className}
+ id={id}
+ onMouseDown={onMouseDown}
+ style={{ '--inputSize': INPUT_SIZES[size] }}
+ title={
+ tooShort && isDefined(minLength)
+ ? intl.formatMessage({ id: 'select2.tooShort' }, { 0: minLength })
+ : ''
+ }
+ >
+ <StyledInputWrapper className="sw-flex sw-items-center">
+ {children}
+ <Spinner className="sw-z-normal" loading={loading ?? false}>
+ <StyledSearchIcon />
+ </Spinner>
+ {value !== '' && (
+ <StyledInteractiveIcon
+ Icon={CloseIcon}
+ aria-label={intl.formatMessage({ id: 'clear' })}
+ className="it__search-box-clear"
+ onClick={() => {
+ onChange('');
+ }}
+ size="small"
+ />
+ )}
+
+ {tooShort && isDefined(minLength) && (
+ <StyledNote className="sw-ml-1" role="note">
+ {intl.formatMessage({ id: 'select2.tooShort' }, { 0: minLength })}
+ </StyledNote>
+ )}
+ </StyledInputWrapper>
+ </InputSearchWrapper>
+ );
+}
+
+SearchSelectControlledInput.displayName = 'SearchSelectControlledInput'; // so that tests don't see the obfuscated production name
declare module 'react-select/dist/declarations/src/Select' {
export interface Props<Option, IsMulti extends boolean, Group extends GroupBase<Option>> {
- clearIconLabel?: string;
minLength?: number;
- tooShortText?: string;
}
}
function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
return renderWithContext(
<InputSearch
- clearIconAriaLabel=""
maxLength={150}
minLength={2}
onChange={jest.fn()}
placeholder="placeholder"
searchInputAriaLabel=""
- tooShortText="too short"
value="foo"
{...props}
/>
function renderMultiselect(props: Partial<MultiSelectMenu['props']> = {}) {
return renderWithContext(
<MultiSelectMenu
- clearIconAriaLabel="clear"
createElementLabel="create thing"
elements={[]}
filterSelected={jest.fn()}
--- /dev/null
+/*
+ * 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 { renderWithContext } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { InputSearch } from '../InputSearch';
+import { SearchSelectControlledInput } from '../SearchSelectControlledInput';
+
+it('should work properly when input is passed as a children', async () => {
+ const onChange = jest.fn();
+ const { user } = setupWithProps({
+ onChange,
+ value: 'foo',
+ children: <input onChange={onChange} />,
+ });
+ await user.click(screen.getByLabelText('clear'));
+ expect(onChange).toHaveBeenCalledWith('');
+});
+
+it('should warn when input is too short', () => {
+ setupWithProps({
+ value: 'f',
+ children: <input />,
+ });
+ expect(screen.getByRole('note')).toBeInTheDocument();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof InputSearch>> = {}) {
+ return renderWithContext(
+ <SearchSelectControlledInput
+ maxLength={150}
+ minLength={2}
+ onChange={jest.fn()}
+ placeholder="placeholder"
+ searchInputAriaLabel=""
+ value="foo"
+ {...props}
+ />
+ );
+}
placeholder={translate('search.search_for_projects')}
size="auto"
value={query}
- tooShortText={translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)}
searchInputAriaLabel={translate('search_verb')}
- clearIconAriaLabel={translate('clear')}
/>
</Popup>
</div>
searchItemListWrapper: byRole('menu'),
searchItem: byRole('menuitem'),
showMoreButton: byRole('menuitem', { name: 'show_more' }),
- tooShortWarning: byText('select2.tooShort.2'),
+ tooShortWarning: byText('select2.tooShort'),
noResultTextABCD: byText('no_results_for_x.abcd'),
};
size="auto"
value={query}
searchInputAriaLabel={translate('search_verb')}
- clearIconAriaLabel={translate('clear')}
/>
<MenuItemList
branchLikeTree={branchLikesToDisplayTree}
</div>
)}
<InputSearch
- clearIconAriaLabel={translate('clear')}
searchInputAriaLabel={searchPlaceholder}
minLength={3}
onChange={this.handleQueryChange}
<>
<div className="sw-mb-10 sw-w-abs-400">
<InputSearch
- clearIconAriaLabel={translate('clear')}
onChange={props.onSearch}
placeholder={translate('onboarding.create_project.search_projects_repositories')}
size="full"
<div>
<div className="sw-flex sw-items-center sw-mb-6 sw-w-abs-400">
<InputSearch
- clearIconAriaLabel={translate('clear')}
loading={searching}
minLength={3}
onChange={props.onSearch}
<div className="sw-mb-10 sw-w-abs-400">
<InputSearch
searchInputAriaLabel={translate('onboarding.create_project.search_repositories_by_name')}
- clearIconAriaLabel={translate('clear')}
onChange={props.onSearch}
placeholder={translate('onboarding.create_project.search_repositories_by_name')}
size="full"
onChange={props.onSearch}
placeholder={translate('onboarding.create_project.search_repositories')}
value={searchQuery}
- clearIconAriaLabel={translate('clear')}
/>
</div>
onChange={props.onSearch}
placeholder={translate('onboarding.create_project.search_repositories')}
value={searchQuery}
- clearIconAriaLabel={translate('clear')}
+ minLength={3}
+ loading={searching}
/>
{projects.length === 0 ? (
? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
: translate('select2.noMatches')
}
- tooShortText={translateWithParameters('search.tooShort', MIN_QUERY_LENGTH)}
placeholder={translate('search.search_for_users')}
controlLabel={controlLabel}
controlAriaLabel={translate('issue_bulk_change.assignee.change')}
<TagsSelector
allowNewElements={allowCreation}
createElementLabel={translateWithParameters('issue.create_tag')}
- clearIconAriaLabel={translate('clear')}
headerLabel={translate('issue_bulk_change.select_tags')}
noResultsLabel={translate('no_results')}
onSelect={onSelect}
size="auto"
value={this.state.query}
searchInputAriaLabel={translate('search_verb')}
- clearIconAriaLabel={translate('clear')}
minLength={minSearchLength}
- tooShortText={translateWithParameters('select2.tooShort', minSearchLength)}
/>
);
}
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value=""
/>
<FacetItemsList
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value=""
/>
<FacetItemsList
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value="query"
/>
<FacetItemsList
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value="query"
/>
<FacetItemsList
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value=""
/>
<FacetItemsList
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value="blabla"
/>
<div
<InputSearch
autoFocus={false}
className="it__search-box-input sw-mb-4 sw-w-full"
- clearIconAriaLabel="clear"
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
searchInputAriaLabel="search_verb"
size="auto"
- tooShortText="select2.tooShort.2"
value="blabla"
/>
<div
<TagsSelector
headerLabel={translate('tags')}
searchInputAriaLabel={translate('search.search_for_tags')}
- clearIconAriaLabel={translate('clear')}
createElementLabel={translate('issue.create_tag')}
noResultsLabel={translate('no_results')}
onSearch={onSearch}
import * as React from 'react';
import HomePageSelect from '../../../components/controls/HomePageSelect';
import Tooltip from '../../../components/controls/Tooltip';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { translate } from '../../../helpers/l10n';
import { RawQuery } from '../../../types/types';
import { CurrentUser, isLoggedIn } from '../../../types/users';
import ApplicationCreation from './ApplicationCreation';
size="auto"
placeholder={translate('search.search_for_projects')}
value={query.search ?? ''}
- tooShortText={translateWithParameters('select2.tooShort', MIN_SEARCH_QUERY_LENGTH)}
searchInputAriaLabel={translate('search_verb')}
- clearIconAriaLabel={translate('clear')}
/>
</Tooltip>
<PerspectiveSelect onChange={props.onPerspectiveChange} view={view} />
minLength={minSearchLength}
isDiscreet
controlLabel={controlLabel}
- tooShortText={translateWithParameters('search.tooShort', String(minSearchLength))}
placeholder={translate('search.search_for_users')}
aria-label={translate('search.search_for_users')}
/>
className="sw-w-full"
placeholder={translate('api_documentation.v2.search')}
onChange={setSearch}
- clearIconAriaLabel={translate('clear')}
value={search}
/>
return (
<MultiSelectMenu
- clearIconAriaLabel={translate('clear_verb')}
createElementLabel=""
searchInputAriaLabel={translate('project_activity.graphs.custom.select_metric')}
allowNewElements={false}
onMenuClose={handleClose}
isDiscreet
controlLabel={controlLabel}
- tooShortText={translateWithParameters('search.tooShort', String(minSearchLength))}
placeholder={translate('search.search_for_users')}
aria-label={translate('search.search_for_users')}
zLevel={PopupZLevel.Absolute}
<TagsSelector
headerLabel={translate('issue.tags')}
searchInputAriaLabel={translate('search.search_for_tags')}
- clearIconAriaLabel={translate('clear')}
createElementLabel={translate('issue.create_tag')}
noResultsLabel={translate('no_results')}
onSearch={onSearch}