diff options
Diffstat (limited to 'server/sonar-web/src/main/js/components')
16 files changed, 521 insertions, 132 deletions
diff --git a/server/sonar-web/src/main/js/components/SelectList/index.js b/server/sonar-web/src/main/js/components/SelectList/index.js index 97339623edb..1b970113d59 100644 --- a/server/sonar-web/src/main/js/components/SelectList/index.js +++ b/server/sonar-web/src/main/js/components/SelectList/index.js @@ -25,6 +25,7 @@ import { translate } from '../../helpers/l10n'; import ItemTemplate from './templates/item.hbs'; import ListTemplate from './templates/list.hbs'; import './styles.css'; +import '../controls/SearchBox.css'; let showError = null; @@ -160,7 +161,8 @@ const SelectListView = Backbone.View.extend({ events: { 'click .select-list-control-button[name=selected]': 'showSelected', 'click .select-list-control-button[name=deselected]': 'showDeselected', - 'click .select-list-control-button[name=all]': 'showAll' + 'click .select-list-control-button[name=all]': 'showAll', + 'click .js-reset': 'onResetClick' }, initialize(options) { @@ -331,6 +333,7 @@ const SelectListView = Backbone.View.extend({ this.$('.select-list-check-control').toggleClass('disabled', hasQuery); this.$('.select-list-search-control').toggleClass('disabled', !hasQuery); + this.$('.js-reset').toggleClass('hidden', !hasQuery); if (hasQuery) { this.showFetchSpinner(); @@ -352,6 +355,15 @@ const SelectListView = Backbone.View.extend({ } }, + onResetClick(e) { + e.preventDefault(); + e.currentTarget.blur(); + this.$('.select-list-search-control input') + .val('') + .focus() + .trigger('search'); + }, + searchByQuery(query) { this.$('.select-list-search-control input').val(query); this.search(); diff --git a/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs b/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs index 95b602d96af..fe9379484ea 100644 --- a/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs +++ b/server/sonar-web/src/main/js/components/SelectList/templates/list.hbs @@ -4,10 +4,19 @@ <a class="select-list-control-button" name="selected">{{this.selected}}</a><a class="select-list-control-button" name="deselected">{{this.deselected}}</a><a class="select-list-control-button" name="all">{{this.all}}</a> </div> <div class="select-list-search-control"> - <form class="search-box"> - <span class="search-box-submit button-clean"><i class="icon-search"></i></span> - <input class="search-box-input" type="search" name="q" placeholder="{{t 'search_verb'}}" maxlength="100" autocomplete="off"> - </form> + <div class="search-box"> + <input class="search-box-input" type="text" name="q" placeholder="{{t 'search_verb'}}" maxlength="100" autocomplete="off"> + <svg class="search-box-magnifier" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"> + <g transform="matrix(0.0288462,0,0,0.0288462,2,1.07692)"> + <path d="M288,208C288,177.167 277.042,150.792 255.125,128.875C233.208,106.958 206.833,96 176,96C145.167,96 118.792,106.958 96.875,128.875C74.958,150.792 64,177.167 64,208C64,238.833 74.958,265.208 96.875,287.125C118.792,309.042 145.167,320 176,320C206.833,320 233.208,309.042 255.125,287.125C277.042,265.208 288,238.833 288,208ZM416,416C416,424.667 412.833,432.167 406.5,438.5C400.167,444.833 392.667,448 384,448C375,448 367.5,444.833 361.5,438.5L275.75,353C245.917,373.667 212.667,384 176,384C152.167,384 129.375,379.375 107.625,370.125C85.875,360.875 67.125,348.375 51.375,332.625C35.625,316.875 23.125,298.125 13.875,276.375C4.625,254.625 0,231.833 0,208C0,184.167 4.625,161.375 13.875,139.625C23.125,117.875 35.625,99.125 51.375,83.375C67.125,67.625 85.875,55.125 107.625,45.875C129.375,36.625 152.167,32 176,32C199.833,32 222.625,36.625 244.375,45.875C266.125,55.125 284.875,67.625 300.625,83.375C316.375,99.125 328.875,117.875 338.125,139.625C347.375,161.375 352,184.167 352,208C352,244.667 341.667,277.917 321,307.75L406.75,393.5C412.917,399.667 416,407.167 416,416Z" style="fill:currentColor;fill-rule:nonzero;"/> + </g> + </svg> + <button class="js-reset hidden button-tiny search-box-clear button-icon" style="color: rgb(153, 153, 153);" type="reset"> + <svg width="12" height="12" viewBox="0 0 16 16" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"> + <path d="M14 4.242L11.758 2l-3.76 3.76L4.242 2 2 4.242l3.756 3.756L2 11.758 4.242 14l3.756-3.76 3.76 3.76L14 11.758l-3.76-3.76L14 4.242z" style="fill: currentcolor;"/> + </svg> + </button> + </div> </div> </div> <div class="select-list-list-container"> diff --git a/server/sonar-web/src/main/js/components/common/MultiSelect.js b/server/sonar-web/src/main/js/components/common/MultiSelect.js index 9bfed621cc0..30b1761bac6 100644 --- a/server/sonar-web/src/main/js/components/common/MultiSelect.js +++ b/server/sonar-web/src/main/js/components/common/MultiSelect.js @@ -21,6 +21,7 @@ import React from 'react'; import { difference } from 'lodash'; import MultiSelectOption from './MultiSelectOption'; +import SearchBox from '../controls/SearchBox'; import { translate } from '../../helpers/l10n'; /*:: @@ -31,7 +32,8 @@ type Props = { onSearch: string => void, onSelect: string => void, onUnselect: string => void, - validateSearchInput: string => string + validateSearchInput: string => string, + placeholder: string }; */ @@ -104,8 +106,8 @@ export default class MultiSelect extends React.PureComponent { } }; - handleSearchChange = ({ target } /*: { target: HTMLInputElement } */) => { - this.onSearchQuery(this.props.validateSearchInput(target.value)); + handleSearchChange = (value /*: string */) => { + this.onSearchQuery(this.props.validateSearchInput(value)); }; handleElementHover = (element /*: string */) => { @@ -232,18 +234,12 @@ export default class MultiSelect extends React.PureComponent { return ( <div className="multi-select" ref={div => (this.container = div)}> - <div className="search-box menu-search"> - <button className="search-box-submit button-clean"> - <i className="icon-search-new" /> - </button> - <input - type="search" - value={query} - className="search-box-input" - placeholder={translate('search_verb')} + <div className="menu-search"> + <SearchBox + autoFocus={true} onChange={this.handleSearchChange} - autoComplete="off" - ref={input => (this.searchInput = input)} + placeholder={this.props.placeholder} + value={query} /> </div> <ul className="menu"> diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js index fe5e7370ea0..ed9fb241568 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js @@ -26,7 +26,8 @@ const props = { elements: [], onSearch: () => {}, onSelect: () => {}, - onUnselect: () => {} + onUnselect: () => {}, + placeholder: '' }; const elements = ['foo', 'bar', 'baz']; diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap index dd5d323ec6d..5af710f8bb1 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap @@ -5,21 +5,12 @@ exports[`should render multiselect with selected elements 1`] = ` className="multi-select" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input - autoComplete="off" - className="search-box-input" + <SearchBox + autoFocus={true} onChange={[Function]} - placeholder="search_verb" - type="search" + placeholder="" value="" /> </div> @@ -44,21 +35,12 @@ exports[`should render multiselect with selected elements 2`] = ` className="multi-select" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input - autoComplete="off" - className="search-box-input" + <SearchBox + autoFocus={true} onChange={[Function]} - placeholder="search_verb" - type="search" + placeholder="" value="" /> </div> @@ -101,21 +83,12 @@ exports[`should render multiselect with selected elements 3`] = ` className="multi-select" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input - autoComplete="off" - className="search-box-input" + <SearchBox + autoFocus={true} onChange={[Function]} - placeholder="search_verb" - type="search" + placeholder="" value="" /> </div> @@ -158,21 +131,12 @@ exports[`should render multiselect with selected elements 4`] = ` className="multi-select" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input - autoComplete="off" - className="search-box-input" + <SearchBox + autoFocus={true} onChange={[Function]} - placeholder="search_verb" - type="search" + placeholder="" value="test" /> </div> diff --git a/server/sonar-web/src/main/js/components/controls/SearchBox.css b/server/sonar-web/src/main/js/components/controls/SearchBox.css new file mode 100644 index 00000000000..64951c5f907 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/SearchBox.css @@ -0,0 +1,102 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +.search-box { + position: relative; + display: inline-block; + vertical-align: middle; + font-size: 0; + white-space: nowrap; +} + +.search-box, +.search-box-input { + width: 100%; + max-width: 300px; +} + +.search-box-input { + /* for magnifier icon */ + padding-left: var(--controlHeight) !important; + /* for clear button */ + padding-right: var(--controlHeight) !important; + font-size: var(--baseFontSize); +} + +.search-box-input::placeholder { + color: var(--secondFontColor); + opacity: 1; +} + +.search-box-input::-webkit-search-decoration, +.search-box-input::-webkit-search-cancel-button, +.search-box-input::-webkit-search-results-button, +.search-box-input::-webkit-search-results-decoration { + -webkit-appearance: none; + display: none; +} + +.search-box-input::-ms-clear, +.search-box-input::-ms-reveal { + display: none; + width: 0; + height: 0; +} + +.search-box-note { + position: absolute; + top: 1px; + left: 40px; + right: var(--controlHeight); + line-height: calc(var(--controlHeight)); + color: var(--secondFontColor); + font-size: var(--smallFontSize); + text-align: right; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.search-box-input:focus ~ .search-box-magnifier { + color: var(--blue); +} + +.search-box-magnifier { + position: absolute; + top: 4px; + left: 4px; + color: var(--gray60); + transition: color 0.3s ease; +} + +.search-box-clear { + position: absolute; + top: 4px; + right: 4px; +} + +.search-box-input-note { + position: absolute; + top: 100%; + left: 0; + line-height: 1; + color: var(--secondFontColor); + font-size: var(--smallFontSize); + white-space: nowrap; +} diff --git a/server/sonar-web/src/main/js/components/controls/SearchBox.tsx b/server/sonar-web/src/main/js/components/controls/SearchBox.tsx new file mode 100644 index 00000000000..08ebca7b08b --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/SearchBox.tsx @@ -0,0 +1,167 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:contact 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 * as React from 'react'; +import * as classNames from 'classnames'; +import { debounce, Cancelable } from 'lodash'; +import SearchIcon from '../icons-components/SearchIcon'; +import ClearIcon from '../icons-components/ClearIcon'; +import { ButtonIcon } from '../ui/buttons'; +import * as theme from '../../app/theme'; +import { translateWithParameters } from '../../helpers/l10n'; +import './SearchBox.css'; + +interface Props { + autoFocus?: boolean; + innerRef?: (node: HTMLInputElement | null) => void; + minLength?: number; + onChange: (value: string) => void; + onClick?: React.MouseEventHandler<HTMLInputElement>; + onFocus?: React.FocusEventHandler<HTMLInputElement>; + onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>; + placeholder: string; + value?: string; +} + +interface State { + value: string; +} + +export default class SearchBox extends React.PureComponent<Props, State> { + debouncedOnChange: ((query: string) => void) & Cancelable; + input: HTMLInputElement | null; + + constructor(props: Props) { + super(props); + this.state = { value: props.value || '' }; + this.debouncedOnChange = debounce(this.props.onChange, 250); + } + + componentWillReceiveProps(nextProps: Props) { + if ( + // input is controlled + nextProps.value !== undefined && + // parent is aware of last change + // can happen when previous value was less than min length + this.state.value === this.props.value && + nextProps.value !== this.state.value + ) { + this.setState({ value: nextProps.value }); + } + } + + changeValue = (value: string, debounced = true) => { + const { minLength } = this.props; + if (value.length === 0) { + // immediately notify when value is empty + this.props.onChange(''); + // and cancel scheduled callback + this.debouncedOnChange.cancel(); + } else if (!minLength || minLength <= value.length) { + if (debounced) { + this.debouncedOnChange(value); + } else { + this.props.onChange(value); + } + } + }; + + handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => { + const { value } = event.currentTarget; + this.setState({ value }); + this.changeValue(value); + }; + + handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + if (event.keyCode === 27) { + // escape + event.preventDefault(); + this.handleResetClick(); + } + if (this.props.onKeyDown) { + this.props.onKeyDown(event); + } + }; + + handleResetClick = () => { + this.changeValue('', false); + if (this.props.value === undefined) { + this.setState({ value: '' }); + } + if (this.input) { + this.input.focus(); + } + }; + + ref = (node: HTMLInputElement | null) => { + this.input = node; + if (this.props.innerRef) { + this.props.innerRef(node); + } + }; + + render() { + const { minLength } = this.props; + const { value } = this.state; + + const inputClassName = classNames('search-box-input', { + touched: value.length > 0 && (!minLength || minLength > value.length) + }); + + const tooShort = minLength !== undefined && value.length > 0 && value.length < minLength; + + return ( + <div className="search-box"> + <input + autoComplete="off" + autoFocus={this.props.autoFocus} + className={inputClassName} + maxLength={100} + onChange={this.handleInputChange} + onClick={this.props.onClick} + onFocus={this.props.onFocus} + onKeyDown={this.handleInputKeyDown} + placeholder={this.props.placeholder} + ref={this.ref} + type="search" + value={value} + /> + + <SearchIcon className="search-box-magnifier" /> + + {value && ( + <ButtonIcon + className="button-tiny search-box-clear" + color={theme.gray60} + onClick={this.handleResetClick}> + <ClearIcon size={12} /> + </ButtonIcon> + )} + + {tooShort && ( + <span + className="search-box-note" + title={translateWithParameters('select2.tooShort', minLength!)}> + {translateWithParameters('select2.tooShort', minLength!)} + </span> + )} + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/SearchBox-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/SearchBox-test.tsx new file mode 100644 index 00000000000..b029de18217 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/SearchBox-test.tsx @@ -0,0 +1,84 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:contact 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 * as React from 'react'; +import { shallow, mount } from 'enzyme'; +import SearchBox from '../SearchBox'; +import { click, change } from '../../../helpers/testUtils'; + +jest.mock('lodash', () => { + const lodash = require.requireActual('lodash'); + const debounce = (fn: Function) => { + const debounced: any = (...args: any[]) => fn(...args); + debounced.cancel = jest.fn(); + return debounced; + }; + return Object.assign({}, lodash, { debounce }); +}); + +it('renders', () => { + const wrapper = shallow( + <SearchBox minLength={2} onChange={jest.fn()} placeholder="placeholder" value="foo" /> + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('warns when input is too short', () => { + const wrapper = shallow( + <SearchBox minLength={2} onChange={jest.fn()} placeholder="placeholder" value="f" /> + ); + expect(wrapper.find('.search-box-note').exists()).toBeTruthy(); +}); + +it('shows clear button only when there is a value', () => { + const wrapper = shallow(<SearchBox onChange={jest.fn()} placeholder="placeholder" value="f" />); + expect(wrapper.find('.search-box-clear').exists()).toBeTruthy(); + wrapper.setProps({ value: '' }); + expect(wrapper.find('.search-box-clear').exists()).toBeFalsy(); +}); + +it('attaches ref', () => { + const ref = jest.fn(); + mount(<SearchBox innerRef={ref} onChange={jest.fn()} placeholder="placeholder" value="f" />); + expect(ref).toBeCalled(); + expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement); +}); + +it('resets', () => { + const onChange = jest.fn(); + const wrapper = shallow(<SearchBox onChange={onChange} placeholder="placeholder" value="f" />); + click(wrapper.find('.search-box-clear')); + expect(onChange).toBeCalledWith(''); +}); + +it('changes', () => { + const onChange = jest.fn(); + const wrapper = shallow(<SearchBox onChange={onChange} placeholder="placeholder" value="f" />); + change(wrapper.find('.search-box-input'), 'foo'); + expect(onChange).toBeCalledWith('foo'); +}); + +it('does not change when value is too short', () => { + const onChange = jest.fn(); + const wrapper = shallow( + <SearchBox minLength={3} onChange={onChange} placeholder="placeholder" value="" /> + ); + change(wrapper.find('.search-box-input'), 'fo'); + expect(onChange).not.toBeCalled(); +}); diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap new file mode 100644 index 00000000000..d69e12de1e3 --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders 1`] = ` +<div + className="search-box" +> + <input + autoComplete="off" + className="search-box-input" + maxLength={100} + onChange={[Function]} + onKeyDown={[Function]} + placeholder="placeholder" + type="search" + value="foo" + /> + <SearchIcon + className="search-box-magnifier" + /> + <ButtonIcon + className="button-tiny search-box-clear" + color="#999" + onClick={[Function]} + > + <ClearIcon + size={12} + /> + </ButtonIcon> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/icons-components/SearchIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/SearchIcon.tsx new file mode 100644 index 00000000000..ad4d513bdc9 --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/SearchIcon.tsx @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:contact 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 * as React from 'react'; +import { IconProps } from './types'; + +export default function SearchIcon({ className, fill = 'currentColor', size = 16 }: IconProps) { + return ( + <svg + className={className} + width={size} + height={size} + viewBox="0 0 16 16" + version="1.1" + xmlnsXlink="http://www.w3.org/1999/xlink" + xmlSpace="preserve"> + <path + style={{ fill }} + d="M10.308 7.077c0-.89-.316-1.65-.949-2.283a3.111 3.111 0 0 0-2.282-.948c-.89 0-1.65.316-2.283.948a3.111 3.111 0 0 0-.948 2.283c0 .89.316 1.65.948 2.282a3.111 3.111 0 0 0 2.283.949c.89 0 1.65-.316 2.282-.949a3.111 3.111 0 0 0 .949-2.282zm3.692 6c0 .25-.091.466-.274.649a.887.887 0 0 1-.65.274.857.857 0 0 1-.648-.274L9.954 11.26c-.86.596-1.82.894-2.877.894a4.989 4.989 0 0 1-1.972-.4 5.076 5.076 0 0 1-1.623-1.082A5.076 5.076 0 0 1 2.4 9.049 4.989 4.989 0 0 1 2 7.077c0-.688.133-1.345.4-1.972a5.076 5.076 0 0 1 1.082-1.623A5.076 5.076 0 0 1 5.105 2.4 4.989 4.989 0 0 1 7.077 2c.687 0 1.345.133 1.972.4a5.076 5.076 0 0 1 1.623 1.082c.454.454.815.995 1.082 1.623.266.627.4 1.284.4 1.972a4.938 4.938 0 0 1-.894 2.877l2.473 2.474a.883.883 0 0 1 .267.649z" + /> + </svg> + ); +} diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js index 365ed241de1..d25651d6c6c 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.js @@ -19,11 +19,12 @@ */ // @flow import React from 'react'; -import { debounce, map } from 'lodash'; +import { map } from 'lodash'; import Avatar from '../../../components/ui/Avatar'; import BubblePopup from '../../../components/common/BubblePopup'; import SelectList from '../../../components/common/SelectList'; import SelectListItem from '../../../components/common/SelectListItem'; +import SearchBox from '../../../components/controls/SearchBox'; import getCurrentUserFromStore from '../../../app/utils/getCurrentUserFromStore'; import { areThereCustomOrganizations } from '../../../store/organizations/utils'; import { searchMembers } from '../../../api/organizations'; @@ -68,8 +69,6 @@ export default class SetAssigneePopup extends React.PureComponent { constructor(props /*: Props */) { super(props); this.organizationEnabled = areThereCustomOrganizations(); - this.searchUsers = debounce(this.searchUsers, 250); - this.searchMembers = debounce(this.searchMembers, 250); this.defaultUsersArray = [{ login: '', name: translate('unassigned') }]; const currentUser = getCurrentUserFromStore(); @@ -103,9 +102,8 @@ export default class SetAssigneePopup extends React.PureComponent { }); }; - handleSearchChange = (evt /*: SyntheticInputEvent */) => { - const query = evt.target.value; - if (query.length < 2) { + handleSearchChange = (query /*: string */) => { + if (query.length === 0) { this.setState({ query, users: this.defaultUsersArray, @@ -127,18 +125,13 @@ export default class SetAssigneePopup extends React.PureComponent { position={this.props.popupPosition} customClass="bubble-popup-menu bubble-popup-bottom"> <div className="multi-select"> - <div className="search-box menu-search"> - <button className="search-box-submit button-clean"> - <i className="icon-search-new" /> - </button> - <input - type="search" - value={this.state.query} - className="search-box-input" - placeholder={translate('search_verb')} - onChange={this.handleSearchChange} - autoComplete="off" + <div className="menu-search"> + <SearchBox autoFocus={true} + minLength={2} + onChange={this.handleSearchChange} + placeholder={translate('search.search_for_users')} + value={this.state.query} /> </div> <SelectList diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js index a9ac6e5d328..a9a3617af05 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js @@ -19,7 +19,7 @@ */ //@flow import React from 'react'; -import { debounce, without } from 'lodash'; +import { without } from 'lodash'; import TagsSelector from '../../../components/tags/TagsSelector'; import { searchIssueTags } from '../../../api/issues'; @@ -44,13 +44,7 @@ const LIST_SIZE = 10; export default class SetIssueTagsPopup extends React.PureComponent { /*:: mounted: boolean; */ /*:: props: Props; */ - /*:: state: State; */ - - constructor(props /*: Props */) { - super(props); - this.state = { searchResult: [] }; - this.onSearch = debounce(this.onSearch, 250); - } + state /*: State */ = { searchResult: [] }; componentDidMount() { this.mounted = true; @@ -63,7 +57,7 @@ export default class SetIssueTagsPopup extends React.PureComponent { onSearch = (query /*: string */) => { searchIssueTags({ - q: query || '', + q: query, ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100), organization: this.props.organization }).then((tags /*: Array<string> */) => { @@ -83,6 +77,7 @@ export default class SetIssueTagsPopup extends React.PureComponent { render() { return ( + // $FlowFixMe `this.props.popupPosition` is passed from `BabelPopupHelper` <TagsSelector position={this.props.popupPosition} tags={this.state.searchResult} diff --git a/server/sonar-web/src/main/js/components/tags/TagsSelector.js b/server/sonar-web/src/main/js/components/tags/TagsSelector.js index 979f6e3aeef..2db1235e5f1 100644 --- a/server/sonar-web/src/main/js/components/tags/TagsSelector.js +++ b/server/sonar-web/src/main/js/components/tags/TagsSelector.js @@ -21,6 +21,7 @@ import React from 'react'; import BubblePopup from '../common/BubblePopup'; import MultiSelect from '../common/MultiSelect'; +import { translate } from '../../helpers/l10n'; import './TagsList.css'; /*:: @@ -35,31 +36,26 @@ type Props = { }; */ -export default class TagsSelector extends React.PureComponent { - /*:: validateTag: string => string; */ - - /*:: props: Props; */ - - validateTag(value /*: string */) { - // Allow only a-z, 0-9, '+', '-', '#', '.' - return value.toLowerCase().replace(/[^a-z0-9\+\-#.]/gi, ''); - } +export default function TagsSelector(props /*: Props */) { + return ( + <BubblePopup + position={props.position} + customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300"> + <MultiSelect + elements={props.tags} + selectedElements={props.selectedTags} + listSize={props.listSize} + onSearch={props.onSearch} + onSelect={props.onSelect} + onUnselect={props.onUnselect} + validateSearchInput={validateTag} + placeholder={translate('search.search_for_tags')} + /> + </BubblePopup> + ); +} - render() { - return ( - <BubblePopup - position={this.props.position} - customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300"> - <MultiSelect - elements={this.props.tags} - selectedElements={this.props.selectedTags} - listSize={this.props.listSize} - onSearch={this.props.onSearch} - onSelect={this.props.onSelect} - onUnselect={this.props.onUnselect} - validateSearchInput={this.validateTag} - /> - </BubblePopup> - ); - } +export function validateTag(value /*: string */) { + // Allow only a-z, 0-9, '+', '-', '#', '.' + return value.toLowerCase().replace(/[^a-z0-9\+\-#.]/gi, ''); } diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js index 6ef4a86dd89..395e8fd104f 100644 --- a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js @@ -19,7 +19,7 @@ */ import { shallow } from 'enzyme'; import React from 'react'; -import TagsSelector from '../TagsSelector'; +import TagsSelector, { validateTag } from '../TagsSelector'; const props = { position: { left: 0, top: 0 }, @@ -41,10 +41,9 @@ it('should render without tags at all', () => { it('should validate tags correctly', () => { const validChars = 'abcdefghijklmnopqrstuvwxyz0123456789+-#.'; - const tagsSelector = shallow(<TagsSelector {...props} />).instance(); - expect(tagsSelector.validateTag('test')).toBe('test'); - expect(tagsSelector.validateTag(validChars)).toBe(validChars); - expect(tagsSelector.validateTag(validChars.toUpperCase())).toBe(validChars); - expect(tagsSelector.validateTag('T E$ST')).toBe('test'); - expect(tagsSelector.validateTag('T E$st!^àéèing1')).toBe('testing1'); + expect(validateTag('test')).toBe('test'); + expect(validateTag(validChars)).toBe(validChars); + expect(validateTag(validChars.toUpperCase())).toBe(validChars); + expect(validateTag('T E$ST')).toBe('test'); + expect(validateTag('T E$st!^àéèing1')).toBe('testing1'); }); diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap index 9afc27fbd7b..e2d57569b31 100644 --- a/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap +++ b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap @@ -22,6 +22,7 @@ exports[`should render with selected tags 1`] = ` onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} + placeholder="search.search_for_tags" selectedElements={ Array [ "bar", @@ -48,6 +49,7 @@ exports[`should render without tags at all 1`] = ` onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} + placeholder="search.search_for_tags" selectedElements={Array []} validateSearchInput={[Function]} /> diff --git a/server/sonar-web/src/main/js/components/ui/buttons.tsx b/server/sonar-web/src/main/js/components/ui/buttons.tsx index e4cd085eb05..0e65602d012 100644 --- a/server/sonar-web/src/main/js/components/ui/buttons.tsx +++ b/server/sonar-web/src/main/js/components/ui/buttons.tsx @@ -43,7 +43,7 @@ export class ButtonIcon extends React.PureComponent<ButtonIconProps> { }; render() { - const { children, className, color = theme.darkBlue, ...props } = this.props; + const { children, className, color = theme.darkBlue, onClick, ...props } = this.props; return ( <button className={classNames(className, 'button-icon')} |