diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2017-11-02 17:50:18 +0100 |
---|---|---|
committer | Stas Vilchik <stas.vilchik@sonarsource.com> | 2017-11-03 14:28:18 +0100 |
commit | 6805619766655e0e2e0d375503d65d5818c0c6a6 (patch) | |
tree | 625b6bba4ef44442f341db0a6552b5aeb7e354f3 /server/sonar-web/src | |
parent | a950deadd1f474fa50fe2de1c5aace5468f23c63 (diff) | |
download | sonarqube-6805619766655e0e2e0d375503d65d5818c0c6a6.tar.gz sonarqube-6805619766655e0e2e0d375503d65d5818c0c6a6.zip |
SONAR-9225 Make all search bars consistent
Diffstat (limited to 'server/sonar-web/src')
68 files changed, 844 insertions, 1129 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx index a4d7899387b..07f7e0508cd 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBranchesMenu.tsx @@ -29,6 +29,7 @@ import { } from '../../../../helpers/branches'; import { translate } from '../../../../helpers/l10n'; import { getProjectBranchUrl } from '../../../../helpers/urls'; +import SearchBox from '../../../../components/controls/SearchBox'; import Tooltip from '../../../../components/controls/Tooltip'; interface Props { @@ -75,8 +76,7 @@ export default class ComponentNavBranchesMenu extends React.PureComponent<Props, } }; - handleSearchChange = (event: React.SyntheticEvent<HTMLInputElement>) => - this.setState({ query: event.currentTarget.value, selected: null }); + handleSearchChange = (query: string) => this.setState({ query, selected: null }); handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { switch (event.keyCode) { @@ -84,10 +84,6 @@ export default class ComponentNavBranchesMenu extends React.PureComponent<Props, event.preventDefault(); this.openSelected(); return; - case 27: - event.preventDefault(); - this.props.onClose(); - return; case 38: event.preventDefault(); this.selectPrevious(); @@ -151,17 +147,12 @@ export default class ComponentNavBranchesMenu extends React.PureComponent<Props, isSelected = (branch: Branch) => branch.name === this.getSelected(); renderSearch = () => ( - <div className="search-box menu-search"> - <button className="search-box-submit button-clean"> - <i className="icon-search-new" /> - </button> - <input + <div className="menu-search"> + <SearchBox autoFocus={true} - className="search-box-input" onChange={this.handleSearchChange} onKeyDown={this.handleKeyDown} - placeholder={translate('search_verb')} - type="search" + placeholder={translate('branches.search_for_branches')} value={this.state.query} /> </div> diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx index 6232c936b4d..ae5b7a80d72 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavBranchesMenu-test.tsx @@ -66,13 +66,13 @@ it('selects next & previous', () => { onClose={jest.fn()} /> ); - elementKeydown(wrapper.find('input'), 40); + elementKeydown(wrapper.find('SearchBox'), 40); wrapper.update(); expect(wrapper.state().selected).toBe('foo'); - elementKeydown(wrapper.find('input'), 40); + elementKeydown(wrapper.find('SearchBox'), 40); wrapper.update(); expect(wrapper.state().selected).toBe('foobar'); - elementKeydown(wrapper.find('input'), 38); + elementKeydown(wrapper.find('SearchBox'), 38); wrapper.update(); expect(wrapper.state().selected).toBe('foo'); }); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap index 7a01722ba4c..6f39c721b9d 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBranchesMenu-test.tsx.snap @@ -5,22 +5,13 @@ exports[`renders list 1`] = ` className="dropdown-menu" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input + <SearchBox autoFocus={true} - className="search-box-input" onChange={[Function]} onKeyDown={[Function]} - placeholder="search_verb" - type="search" + placeholder="branches.search_for_branches" value="" /> </div> @@ -181,22 +172,13 @@ exports[`searches 1`] = ` className="dropdown-menu" > <div - className="search-box menu-search" + className="menu-search" > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search-new" - /> - </button> - <input + <SearchBox autoFocus={true} - className="search-box-input" onChange={[Function]} onKeyDown={[Function]} - placeholder="search_verb" - type="search" + placeholder="branches.search_for_branches" value="bar" /> </div> diff --git a/server/sonar-web/src/main/js/app/components/search/Search.css b/server/sonar-web/src/main/js/app/components/search/Search.css index a3fe6f5b49b..f3730f2b276 100644 --- a/server/sonar-web/src/main/js/app/components/search/Search.css +++ b/server/sonar-web/src/main/js/app/components/search/Search.css @@ -3,6 +3,12 @@ padding-right: 3px; } +.navbar-search .search-box, +.navbar-search .search-box-input { + width: 310px; + max-width: none; +} + .navbar-search-input { vertical-align: middle; width: 310px; @@ -13,22 +19,20 @@ .navbar-search-input-hint { position: absolute; - top: 4px; - right: 30px; + top: 1px; + right: 27px; line-height: var(--controlHeight); font-size: var(--smallFontSize); color: var(--secondFontColor); } -.navbar-search-input-hint.is-shifted { - z-index: 7501; - top: 32px; -} .navbar-search-icon { position: relative; + z-index: var(--aboveNormalZIndex); vertical-align: middle; width: 16px; margin-right: -20px; + background-color: #fff; color: var(--secondFontColor); } @@ -78,9 +82,10 @@ } .global-navbar-search-dropdown { + top: calc(100% + 3px) !important; max-height: 80vh; width: 440px; - padding: 0; + padding: 0 !important; overflow-y: auto; overflow-x: hidden; } diff --git a/server/sonar-web/src/main/js/app/components/search/Search.js b/server/sonar-web/src/main/js/app/components/search/Search.js index 5ea8a0f915d..463c4dc5d73 100644 --- a/server/sonar-web/src/main/js/app/components/search/Search.js +++ b/server/sonar-web/src/main/js/app/components/search/Search.js @@ -23,6 +23,7 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import key from 'keymaster'; import { debounce, keyBy, uniqBy } from 'lodash'; +import { FormattedMessage } from 'react-intl'; import SearchResults from './SearchResults'; import SearchResult from './SearchResult'; import { sortQualifiers } from './utils'; @@ -30,6 +31,7 @@ import { sortQualifiers } from './utils'; import RecentHistory from '../../components/RecentHistory'; import DeferredSpinner from '../../../components/common/DeferredSpinner'; import ClockIcon from '../../../components/common/ClockIcon'; +import SearchBox from '../../../components/controls/SearchBox'; import { getSuggestions } from '../../../api/components'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { scrollToElement } from '../../../helpers/scrolling'; @@ -59,7 +61,7 @@ type State = { */ export default class Search extends React.PureComponent { - /*:: input: HTMLElement; */ + /*:: input: HTMLInputElement | null; */ /*:: mounted: boolean; */ /*:: node: HTMLElement; */ /*:: nodes: { [string]: HTMLElement }; @@ -92,7 +94,9 @@ export default class Search extends React.PureComponent { componentDidMount() { this.mounted = true; key('s', () => { - this.input.focus(); + if (this.input) { + this.input.focus(); + } this.openSearch(); return false; }); @@ -169,6 +173,12 @@ export default class Search extends React.PureComponent { return uniqBy([...components, ...recentlyBrowsed], 'key'); }; + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + search = (query /*: string */) => { if (query.length === 0 || query.length >= 2) { this.setState({ loading: true }); @@ -191,10 +201,10 @@ export default class Search extends React.PureComponent { projects: { ...state.projects, ...keyBy(response.projects, 'key') }, results, selected: list.length > 0 ? list[0] : null, - shortQuery: response.warning === 'short_input' + shortQuery: query.length > 2 && response.warning === 'short_input' })); } - }); + }, this.stopLoading); } else { this.setState({ loading: false }); } @@ -221,12 +231,11 @@ export default class Search extends React.PureComponent { selected: moreResults.length > 0 ? moreResults[0].key : state.selected })); } - }); + }, this.stopLoading); } }; - handleQueryChange = (event /*: { currentTarget: HTMLInputElement } */) => { - const query = event.currentTarget.value; + handleQueryChange = (query /*: string */) => { this.setState({ query, shortQuery: query.length === 1 }); this.search(query); }; @@ -278,10 +287,6 @@ export default class Search extends React.PureComponent { event.preventDefault(); this.openSelected(); return; - case 27: - event.preventDefault(); - this.closeSearch(); - return; case 38: event.preventDefault(); this.selectPrevious(); @@ -297,10 +302,18 @@ export default class Search extends React.PureComponent { this.setState({ selected }); }; + handleClick = (event /*: Event */) => { + event.stopPropagation(); + }; + innerRef = (component /*: string */, node /*: HTMLElement */) => { this.nodes[component] = node; }; + searchInputRef = (node /*: HTMLInputElement | null */) => { + this.input = node; + }; + renderResult = (component /*: Component */) => ( <SearchResult appState={this.props.appState} @@ -326,30 +339,21 @@ export default class Search extends React.PureComponent { return ( <li className={dropdownClassName}> - <DeferredSpinner className="navbar-search-icon" loading={this.state.loading}> - <i className="navbar-search-icon icon-search" /> - </DeferredSpinner> - - <input - autoComplete="off" - className="navbar-search-input js-search-input" - maxLength="30" - name="q" + <DeferredSpinner className="navbar-search-icon" loading={this.state.loading} /> + + <SearchBox + innerRef={this.searchInputRef} + minLength={2} onChange={this.handleQueryChange} - onClick={event => event.stopPropagation()} + onClick={this.handleClick} onFocus={this.openSearch} onKeyDown={this.handleKeyDown} - ref={node => (this.input = node)} placeholder={translate('search.placeholder')} - type="search" value={this.state.query} /> {this.state.shortQuery && ( - <span - className={classNames('navbar-search-input-hint', { - 'is-shifted': this.state.query.length > 5 - })}> + <span className={classNames('navbar-search-input-hint')}> {translateWithParameters('select2.tooShort', 2)} </span> )} @@ -375,12 +379,11 @@ export default class Search extends React.PureComponent { <ClockIcon className="little-spacer-right" size={12} /> {translate('recently_browsed')} </div> - <div - dangerouslySetInnerHTML={{ - __html: translateWithParameters( - 'search.shortcut_hint', - '<span class="shortcut-button shortcut-button-small">s</span>' - ) + <FormattedMessage + defaultMessage={translate('search.shortcut_hint')} + id="search.shortcut_hint" + values={{ + shortcut: <span className="shortcut-button shortcut-button-small">s</span> }} /> </div> diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js index 822d42b58f1..82e8d8d4fae 100644 --- a/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js @@ -38,12 +38,12 @@ function component(key /*: string */, qualifier /*: string */ = 'TRK') { } function next(form /*: ShallowWrapper */, expected /*: string */) { - elementKeydown(form.find('input'), 40); + elementKeydown(form.find('SearchBox'), 40); expect(form.state().selected).toBe(expected); } function prev(form /*: ShallowWrapper */, expected /*: string */) { - elementKeydown(form.find('input'), 38); + elementKeydown(form.find('SearchBox'), 38); expect(form.state().selected).toBe(expected); } @@ -83,7 +83,7 @@ it('opens selected on enter', () => { }); const openSelected = jest.fn(); form.instance().openSelected = openSelected; - elementKeydown(form.find('input'), 13); + elementKeydown(form.find('SearchBox'), 13); expect(openSelected).toBeCalled(); }); @@ -95,14 +95,6 @@ it('shows warning about short input', () => { expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); }); -it('closes on escape', () => { - const form = render(); - form.instance().openSearch(); - expect(form.state().open).toBe(true); - elementKeydown(form.find('input'), 27); - expect(form.state().open).toBe(false); -}); - it('closes on click outside', () => { const form = mount( <Search appState={{ organizationsEnabled: false }} currentUser={{ isLoggedIn: false }} /> diff --git a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap index 86b9f83f770..6541c673539 100644 --- a/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap +++ b/server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap @@ -10,7 +10,7 @@ exports[`shows warning about short input 1`] = ` exports[`shows warning about short input 2`] = ` <span - className="navbar-search-input-hint is-shifted" + className="navbar-search-input-hint" > select2.tooShort.2 </span> diff --git a/server/sonar-web/src/main/js/app/styles/components/menu.css b/server/sonar-web/src/main/js/app/styles/components/menu.css index 62f7314addc..58878f990c1 100644 --- a/server/sonar-web/src/main/js/app/styles/components/menu.css +++ b/server/sonar-web/src/main/js/app/styles/components/menu.css @@ -99,22 +99,10 @@ padding: 4px 16px 0; } +.menu-search .search-box, .menu-search .search-box-input { - font-size: var(--smallFontSize); -} - -.menu-search .search-box-submit { - vertical-align: baseline; -} - -.menu-search-full-width { - display: flex; - align-items: center; -} - -.menu-search-full-width .search-box-input { - flex-grow: 1; - width: auto; + max-width: none; + min-width: 240px; } .menu-search ~ .menu > li > a:hover, diff --git a/server/sonar-web/src/main/js/app/styles/components/search-navigator.css b/server/sonar-web/src/main/js/app/styles/components/search-navigator.css index 39c48ca9fc9..2bfdbb7415b 100644 --- a/server/sonar-web/src/main/js/app/styles/components/search-navigator.css +++ b/server/sonar-web/src/main/js/app/styles/components/search-navigator.css @@ -534,11 +534,7 @@ a.search-navigator-facet:focus .facet-stat { } .search-navigator-facet-query { - padding: 7px 10px 27px; -} - -.search-navigator-facet-query input { - width: 100%; + padding: 7px 0 27px; } .search-navigator-facet-custom-value { diff --git a/server/sonar-web/src/main/js/app/styles/init/icons.css b/server/sonar-web/src/main/js/app/styles/init/icons.css index 82baae3dac5..315803eb185 100644 --- a/server/sonar-web/src/main/js/app/styles/init/icons.css +++ b/server/sonar-web/src/main/js/app/styles/init/icons.css @@ -709,21 +709,6 @@ a:hover > .icon-radio { font-size: var(--bigFontSize); } -.icon-search:before { - content: '\f002'; - font-size: var(--bigFontSize); -} - -.icon-search-new { - display: inline-block; - vertical-align: top; - width: 16px; - height: 16px; - background-size: 13px 14px; - background: no-repeat center center; - background-image: url('data:image/svg+xml,%3Csvg%20width%3D%2213%22%20height%3D%2214%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M9%206.5c0-.964-.342-1.788-1.027-2.473C7.288%203.342%206.463%203%205.5%203c-.964%200-1.788.342-2.473%201.027C2.342%204.712%202%205.537%202%206.5c0%20.964.342%201.788%201.027%202.473C3.712%209.658%204.537%2010%205.5%2010c.964%200%201.788-.342%202.473-1.027C8.658%208.288%209%207.463%209%206.5zm4%206.5c0%20.27-.1.505-.297.703-.198.198-.432.297-.703.297-.28%200-.516-.1-.703-.297l-2.68-2.672c-.932.647-1.97.97-3.117.97-.745%200-1.457-.145-2.137-.434-.68-.29-1.265-.68-1.758-1.171-.492-.493-.882-1.08-1.17-1.758C.144%207.957%200%207.245%200%206.5c0-.745.145-1.457.434-2.137.29-.68.68-1.265%201.17-1.758.494-.492%201.08-.882%201.76-1.17C4.043%201.144%204.753%201%205.5%201c.745%200%201.457.145%202.137.434.68.29%201.265.68%201.758%201.17.492.494.882%201.08%201.17%201.76.29.68.435%201.39.435%202.136%200%201.146-.323%202.185-.97%203.117l2.68%202.68c.194.193.29.427.29.703z%22%20fill%3D%22%23777%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E'); -} - .icon-edit:before { content: '\f040'; font-size: var(--mediumFontSize); diff --git a/server/sonar-web/src/main/js/app/styles/sonar.css b/server/sonar-web/src/main/js/app/styles/sonar.css index bccf053cfe7..6f0a00b31e5 100644 --- a/server/sonar-web/src/main/js/app/styles/sonar.css +++ b/server/sonar-web/src/main/js/app/styles/sonar.css @@ -47,7 +47,6 @@ @import './components/panels.css'; @import './components/badges.css'; @import './components/columns.css'; -@import './components/search.css'; @import './components/side-tabs.css'; @import './components/boxed-group.css'; diff --git a/server/sonar-web/src/main/js/apps/background-tasks/__tests__/background-tasks-test.js b/server/sonar-web/src/main/js/apps/background-tasks/__tests__/background-tasks-test.js index c6c69e6757f..5764615be32 100644 --- a/server/sonar-web/src/main/js/apps/background-tasks/__tests__/background-tasks-test.js +++ b/server/sonar-web/src/main/js/apps/background-tasks/__tests__/background-tasks-test.js @@ -48,19 +48,19 @@ describe('Search', () => { it('should render search form', () => { const component = shallow(<Search {...defaultProps} />); - expect(component.find('.js-search').length).toBe(1); + expect(component.find('SearchBox').exists()).toBeTruthy(); }); it('should not render search form', () => { const component = shallow(<Search {...defaultProps} component={{ id: 'ABCD' }} />); - expect(component.find('.js-search').length).toBe(0); + expect(component.find('SearchBox').exists()).toBeFalsy(); }); it('should search', done => { const searchSpy = jest.fn(); const component = shallow(<Search {...defaultProps} onFilterUpdate={searchSpy} />); - const searchInput = component.find('.js-search'); - change(searchInput, 'some search query'); + const searchInput = component.find('SearchBox'); + searchInput.prop('onChange')('some search query'); setTimeout(() => { expect(searchSpy).toBeCalledWith({ query: 'some search query' }); done(); diff --git a/server/sonar-web/src/main/js/apps/background-tasks/background-tasks.css b/server/sonar-web/src/main/js/apps/background-tasks/background-tasks.css index 74307146109..6f421e50e88 100644 --- a/server/sonar-web/src/main/js/apps/background-tasks/background-tasks.css +++ b/server/sonar-web/src/main/js/apps/background-tasks/background-tasks.css @@ -4,7 +4,7 @@ } .bt-search-form > li + li { - margin-left: 40px; + margin-left: 16px; } .bt-search-form-label { @@ -15,8 +15,8 @@ padding: 4px 0; } -.bt-search-form-right { - margin-left: auto !important; +.bt-search-form-large { + flex: 1; } .bt-workers-warning-icon { diff --git a/server/sonar-web/src/main/js/apps/background-tasks/components/Search.js b/server/sonar-web/src/main/js/apps/background-tasks/components/Search.js index d8bf4203b0c..023ab973177 100644 --- a/server/sonar-web/src/main/js/apps/background-tasks/components/Search.js +++ b/server/sonar-web/src/main/js/apps/background-tasks/components/Search.js @@ -25,6 +25,7 @@ import TypesFilter from './TypesFilter'; import CurrentsFilter from './CurrentsFilter'; import DateFilter from './DateFilter'; import { DEFAULT_FILTERS } from './../constants'; +import SearchBox from '../../../components/controls/SearchBox'; import { translate } from '../../../helpers/l10n'; export default class Search extends React.PureComponent { @@ -54,9 +55,9 @@ export default class Search extends React.PureComponent { this.props.onFilterUpdate(date); } - handleQueryChange(query /*: string */) { + handleQueryChange = (query /*: string */) => { this.props.onFilterUpdate({ query }); - } + }; handleReload(e /*: Object */) { e.target.blur(); @@ -78,18 +79,11 @@ export default class Search extends React.PureComponent { } return ( - <li> - <h6 className="bt-search-form-label"> - {translate('background_tasks.search_by_task_or_component')} - </h6> - - <input - onChange={e => this.handleQueryChange(e.target.value)} + <li className="bt-search-form-large"> + <SearchBox + onChange={this.handleQueryChange} + placeholder={translate('background_tasks.search_by_task_or_component')} value={query} - ref="searchInput" - className="js-search input-medium" - type="search" - placeholder={translate('search_verb')} /> </li> ); @@ -143,7 +137,7 @@ export default class Search extends React.PureComponent { {this.renderSearchBox()} - <li className="bt-search-form-right nowrap"> + <li className="nowrap"> <button className="js-reload" onClick={this.handleReload.bind(this)} disabled={loading}> {translate('reload')} </button>{' '} diff --git a/server/sonar-web/src/main/js/apps/code/code.css b/server/sonar-web/src/main/js/apps/code/code.css index 2de8ede896b..66658e61943 100644 --- a/server/sonar-web/src/main/js/apps/code/code.css +++ b/server/sonar-web/src/main/js/apps/code/code.css @@ -55,20 +55,6 @@ display: none; } -.code-search .search-box { - padding-right: 10px; -} - -.code-search .search-box .note { - vertical-align: middle; - opacity: 0; - transition: opacity 0.3s ease; -} - -.code-search .search-box input.touched ~ .note { - opacity: 1; -} - .code-components-header { position: sticky; top: 95px; diff --git a/server/sonar-web/src/main/js/apps/code/components/Search.tsx b/server/sonar-web/src/main/js/apps/code/components/Search.tsx index 3f254bf87ec..3e666e19317 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Search.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Search.tsx @@ -20,13 +20,13 @@ import * as React from 'react'; import * as PropTypes from 'prop-types'; import * as classNames from 'classnames'; -import { debounce } from 'lodash'; import Components from './Components'; import { getTree } from '../../../api/components'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; import { parseError } from '../utils'; import { getProjectUrl } from '../../../helpers/urls'; import { Component } from '../types'; +import SearchBox from '../../../components/controls/SearchBox'; +import { translate } from '../../../helpers/l10n'; interface Props { branch?: string; @@ -43,7 +43,6 @@ interface State { } export default class Search extends React.PureComponent<Props, State> { - input: HTMLInputElement; mounted: boolean; static contextTypes = { @@ -55,10 +54,6 @@ export default class Search extends React.PureComponent<Props, State> { loading: false }; - componentWillMount() { - this.handleSearch = debounce(this.handleSearch, 250); - } - componentDidMount() { this.mounted = true; } @@ -79,10 +74,6 @@ export default class Search extends React.PureComponent<Props, State> { this.mounted = false; } - checkInputValue(query: string) { - return this.input.value === query; - } - handleSelectNext() { const { selectedIndex, results } = this.state; if (results != null && selectedIndex != null && selectedIndex < results.length - 1) { @@ -114,27 +105,26 @@ export default class Search extends React.PureComponent<Props, State> { } } - handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { - switch (e.keyCode) { + handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { + switch (event.keyCode) { case 13: - e.preventDefault(); + event.preventDefault(); this.handleSelectCurrent(); break; case 38: - e.preventDefault(); + event.preventDefault(); this.handleSelectPrevious(); break; case 40: - e.preventDefault(); + event.preventDefault(); this.handleSelectNext(); break; default: // do nothing } - } + }; handleSearch = (query: string) => { - // first time check if value has changed due to debounce - if (this.mounted && this.checkInputValue(query)) { + if (this.mounted) { const { branch, component, onError } = this.props; this.setState({ loading: true }); @@ -143,8 +133,7 @@ export default class Search extends React.PureComponent<Props, State> { getTree(component.key, { branch, q: query, s: 'qualifier,name', qualifiers }) .then(r => { - // second time check if value has change due to api request - if (this.mounted && this.checkInputValue(query)) { + if (this.mounted) { this.setState({ results: r.components, selectedIndex: r.components.length > 0 ? 0 : undefined, @@ -153,8 +142,7 @@ export default class Search extends React.PureComponent<Props, State> { } }) .catch(e => { - // second time check if value has change due to api request - if (this.mounted && this.checkInputValue(query)) { + if (this.mounted) { this.setState({ loading: false }); parseError(e).then(onError); } @@ -162,61 +150,36 @@ export default class Search extends React.PureComponent<Props, State> { } }; - handleQueryChange(query: string) { + handleQueryChange = (query: string) => { this.setState({ query }); - if (query.length < 3) { + if (query.length === 0) { this.setState({ results: undefined }); } else { this.handleSearch(query); } - } - - handleInputChange(event: React.SyntheticEvent<HTMLInputElement>) { - const query = event.currentTarget.value; - this.handleQueryChange(query); - } - - handleSubmit(event: React.SyntheticEvent<HTMLFormElement>) { - event.preventDefault(); - const query = this.input.value; - this.handleQueryChange(query); - } + }; render() { const { component } = this.props; - const { query, loading, selectedIndex, results } = this.state; + const { loading, selectedIndex, results } = this.state; const selected = selectedIndex != null && results != null ? results[selectedIndex] : undefined; const containerClassName = classNames('code-search', { 'code-search-with-results': results != null }); - const inputClassName = classNames('search-box-input', { - touched: query.length > 0 && query.length < 3 - }); + const isPortfolio = ['VW', 'SVW', 'APP'].includes(component.qualifier); return ( <div id="code-search" className={containerClassName}> - <form className="search-box" onSubmit={this.handleSubmit.bind(this)}> - <button className="search-box-submit button-clean"> - <i className="icon-search" /> - </button> - - <input - ref={node => (this.input = node as HTMLInputElement)} - onKeyDown={this.handleKeyDown.bind(this)} - onChange={this.handleInputChange.bind(this)} - value={query} - className={inputClassName} - type="search" - name="q" - placeholder={translate('search_verb')} - maxLength={100} - autoComplete="off" - /> - - {loading && <i className="spinner spacer-left" />} - - <span className="note spacer-left">{translateWithParameters('select2.tooShort', 3)}</span> - </form> + <SearchBox + minLength={3} + onChange={this.handleQueryChange} + onKeyDown={this.handleKeyDown} + placeholder={translate( + isPortfolio ? 'code.search_placeholder.portfolio' : 'code.search_placeholder' + )} + value={this.state.query} + /> + {loading && <i className="spinner spacer-left" />} {results != null && ( <Components diff --git a/server/sonar-web/src/main/js/apps/coding-rules/facets/query-facet.js b/server/sonar-web/src/main/js/apps/coding-rules/facets/query-facet.js index 3198f422809..3ca0ceb3538 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/facets/query-facet.js +++ b/server/sonar-web/src/main/js/apps/coding-rules/facets/query-facet.js @@ -17,17 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { debounce } from 'lodash'; import BaseFacet from './base-facet'; import Template from '../templates/facets/coding-rules-query-facet.hbs'; export default BaseFacet.extend({ template: Template, - events() { + events(...args) { return { - ...BaseFacet.prototype.events.apply(this, arguments), + ...BaseFacet.prototype.events.apply(this, args), 'submit form': 'onFormSubmit', - 'search input': 'onInputSearch' + 'keyup input': 'onKeyUp', + 'search input': 'onSearch', + 'click .js-reset': 'onResetClick' }; }, @@ -37,6 +40,8 @@ export default BaseFacet.extend({ const value = query.q; if (value != null) { this.$('input').val(value); + this.$('.js-hint').toggleClass('hidden', value.length !== 1); + this.$('.js-reset').toggleClass('hidden', value.length === 0); } }, @@ -45,8 +50,24 @@ export default BaseFacet.extend({ this.applyFacet(); }, - onInputSearch() { - this.applyFacet(); + onKeyUp() { + const q = this.$('input').val(); + this.$('.js-hint').toggleClass('hidden', q.length !== 1); + this.$('.js-reset').toggleClass('hidden', q.length === 0); + }, + + onSearch() { + const q = this.$('input').val(); + if (q.length !== 1) { + this.applyFacet(); + } + }, + + onResetClick(e) { + e.preventDefault(); + this.$('input') + .val('') + .focus(); }, applyFacet() { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/templates/facets/coding-rules-query-facet.hbs b/server/sonar-web/src/main/js/apps/coding-rules/templates/facets/coding-rules-query-facet.hbs index 5cafe6d1cd7..c8f3d84d5b1 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/templates/facets/coding-rules-query-facet.hbs +++ b/server/sonar-web/src/main/js/apps/coding-rules/templates/facets/coding-rules-query-facet.hbs @@ -1,5 +1,18 @@ <div class="search-navigator-facet-query"> - <form> - <input type="search" class="search-navigator-facet-input" name="q" placeholder="{{t 'search_verb'}}"> + <form class="search-box"> + <input class="search-box-input" type="text" name="q" placeholder="{{t 'search.search_for_rules'}}" maxlength="100"> + <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> + <span class="js-hint search-box-note hidden" title="{{tp 'select2.tooShort' 2}}"> + {{tp 'select2.tooShort' 2}} + </span> </form> </div> diff --git a/server/sonar-web/src/main/js/apps/groups/components/GroupsAppContainer.js b/server/sonar-web/src/main/js/apps/groups/components/GroupsAppContainer.js index 16525d55ee5..a8a10e4a2c2 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/GroupsAppContainer.js +++ b/server/sonar-web/src/main/js/apps/groups/components/GroupsAppContainer.js @@ -21,6 +21,7 @@ import React from 'react'; import Helmet from 'react-helmet'; import init from '../init'; import { translate } from '../../../helpers/l10n'; +import '../../../components/controls/SearchBox.css'; export default class GroupsAppContainer extends React.PureComponent { componentDidMount() { diff --git a/server/sonar-web/src/main/js/apps/groups/search-view.js b/server/sonar-web/src/main/js/apps/groups/search-view.js index 9acd7109e57..1c8401913bd 100644 --- a/server/sonar-web/src/main/js/apps/groups/search-view.js +++ b/server/sonar-web/src/main/js/apps/groups/search-view.js @@ -24,10 +24,15 @@ import Template from './templates/groups-search.hbs'; export default Marionette.ItemView.extend({ template: Template, + ui: { + reset: '.js-reset' + }, + events: { 'submit #groups-search-form': 'onFormSubmit', - 'search #groups-search-query': 'debouncedOnKeyUp', - 'keyup #groups-search-query': 'debouncedOnKeyUp' + 'search #groups-search-query': 'initialOnKeyUp', + 'keyup #groups-search-query': 'initialOnKeyUp', + 'click .js-reset': 'onResetClick' }, initialize() { @@ -44,6 +49,12 @@ export default Marionette.ItemView.extend({ this.debouncedOnKeyUp(); }, + initialOnKeyUp() { + const q = this.getQuery(); + this.ui.reset.toggleClass('hidden', q.length === 0); + this.debouncedOnKeyUp(); + }, + onKeyUp() { const q = this.getQuery(); if (q === this._bufferedValue) { @@ -62,5 +73,14 @@ export default Marionette.ItemView.extend({ search(q) { return this.collection.fetch({ reset: true, data: { q } }); + }, + + onResetClick(e) { + e.preventDefault(); + e.currentTarget.blur(); + this.$('#groups-search-query') + .val('') + .focus(); + this.onKeyUp(); } }); diff --git a/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs b/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs index 013f7cba90a..e0d8614362f 100644 --- a/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs +++ b/server/sonar-web/src/main/js/apps/groups/templates/groups-search.hbs @@ -1,6 +1,15 @@ <div class="panel panel-vertical bordered-bottom spacer-bottom"> <form id="groups-search-form" class="search-box"> - <button id="groups-search-submit" class="search-box-submit button-clean"><i class="icon-search"></i></button> - <input id="groups-search-query" class="search-box-input" type="search" name="q" placeholder="{{t 'search_verb'}}" maxlength="100"> + <input id="groups-search-query" class="search-box-input" type="text" name="q" placeholder="{{t 'search.search_by_name'}}" maxlength="100"> + <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> </form> </div> diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js index e75f10633a4..33675c7bbb4 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacetFooter.js @@ -56,7 +56,7 @@ class LanguageFacetFooter extends React.PureComponent { noResultsText={translate('select2.noMatches')} onChange={this.handleChange} options={options} - placeholder={translate('search_verb')} + placeholder={translate('search.search_for_languages')} searchable={true} /> </div> diff --git a/server/sonar-web/src/main/js/apps/marketplace/Search.tsx b/server/sonar-web/src/main/js/apps/marketplace/Search.tsx index 3bef8189711..1eaef17728e 100644 --- a/server/sonar-web/src/main/js/apps/marketplace/Search.tsx +++ b/server/sonar-web/src/main/js/apps/marketplace/Search.tsx @@ -18,9 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { debounce } from 'lodash'; -import RadioToggle from '../../components/controls/RadioToggle'; import { Query } from './utils'; +import RadioToggle from '../../components/controls/RadioToggle'; +import SearchBox from '../../components/controls/SearchBox'; import { translate } from '../../helpers/l10n'; interface Props { @@ -29,33 +29,13 @@ interface Props { updateQuery: (newQuery: Partial<Query>) => void; } -interface State { - search?: string; -} - -export default class Search extends React.PureComponent<Props, State> { - constructor(props: Props) { - super(props); - this.state = { search: props.query.search }; - this.updateSearch = debounce(this.updateSearch, 250); - } - - componentWillReceiveProps(nextProps: Props) { - if (nextProps.query.search !== this.state.search) { - this.setState({ search: nextProps.query.search }); - } - } - - handleSearch = (e: React.SyntheticEvent<HTMLInputElement>) => { - const search = e.currentTarget.value; - this.setState({ search }); - this.updateSearch(search); +export default class Search extends React.PureComponent<Props> { + handleSearch = (search: string) => { + this.props.updateQuery({ search }); }; handleFilterChange = (filter: string) => this.props.updateQuery({ filter }); - updateSearch = (search: string) => this.props.updateQuery({ search }); - render() { const { query, updateCenterActive } = this.props; const radioOptions = [ @@ -77,21 +57,11 @@ export default class Search extends React.PureComponent<Props, State> { value={query.filter} /> </div> - <div className="search-box display-inline-block text-top"> - <button className="search-box-submit button-clean"> - <i className="icon-search" /> - </button> - <input - onChange={this.handleSearch} - value={this.state.search} - className="search-box-input" - type="search" - name="search" - placeholder={translate('search_verb')} - maxLength={100} - autoComplete="off" - /> - </div> + <SearchBox + onChange={this.handleSearch} + placeholder={translate('marketplace.search')} + value={query.search} + /> </div> ); } diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersListHeader.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersListHeader.js index 8303d700623..97371919773 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/MembersListHeader.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersListHeader.js @@ -19,7 +19,7 @@ */ //@flow import React from 'react'; -import UsersSearch from '../../users/components/UsersSearch'; +import SearchBox from '../../../components/controls/SearchBox'; import { formatMeasure } from '../../../helpers/measures'; import { translate } from '../../../helpers/l10n'; @@ -30,21 +30,19 @@ type Props = { }; */ -export default class MembersListHeader extends React.PureComponent { - /*:: props: Props; */ - - render() { - const { total } = this.props; - return ( - <div className="panel panel-vertical bordered-bottom spacer-bottom"> - <UsersSearch onSearch={this.props.handleSearch} className="display-inline-block" /> - {total != null && ( - <span className="pull-right little-spacer-top"> - <strong>{formatMeasure(total, 'INT')}</strong>{' '} - {translate('organization.members.members')} - </span> - )} - </div> - ); - } +export default function MembersListHeader({ handleSearch, total } /*: Props */) { + return ( + <div className="panel panel-vertical bordered-bottom spacer-bottom"> + <SearchBox + minLength={2} + onChange={handleSearch} + placeholder={translate('search.search_for_users')} + /> + {total != null && ( + <span className="pull-right little-spacer-top"> + <strong>{formatMeasure(total, 'INT')}</strong> {translate('organization.members.members')} + </span> + )} + </div> + ); } diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListHeader-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListHeader-test.js.snap index 4dce97f4aef..42461a5120c 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListHeader-test.js.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListHeader-test.js.snap @@ -4,9 +4,10 @@ exports[`should render with the total 1`] = ` <div className="panel panel-vertical bordered-bottom spacer-bottom" > - <UsersSearch - className="display-inline-block" - onSearch={[Function]} + <SearchBox + minLength={2} + onChange={[Function]} + placeholder="search.search_for_users" /> <span className="pull-right little-spacer-top" @@ -24,9 +25,10 @@ exports[`should render without the total 1`] = ` <div className="panel panel-vertical bordered-bottom spacer-bottom" > - <UsersSearch - className="display-inline-block" - onSearch={[Function]} + <SearchBox + minLength={2} + onChange={[Function]} + placeholder="search.search_for_users" /> </div> `; diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js index 76e25c9c1f1..507aa1a8368 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.js +++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.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 { searchProjectTags } from '../../../api/components'; @@ -42,13 +42,7 @@ const LIST_SIZE = 10; export default class MetaTagsSelector extends React.PureComponent { /*:: props: Props; */ - /*:: state: State; */ - - constructor(props /*: Props */) { - super(props); - this.state = { searchResult: [] }; - this.onSearch = debounce(this.onSearch, 250); - } + state /*: State */ = { searchResult: [] }; componentDidMount() { this.onSearch(''); @@ -56,12 +50,10 @@ export default class MetaTagsSelector extends React.PureComponent { onSearch = (query /*: string */) => { searchProjectTags({ - q: query || '', + q: query, ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100) }).then(result => { - this.setState({ - searchResult: result.tags - }); + this.setState({ searchResult: result.tags }); }); }; diff --git a/server/sonar-web/src/main/js/apps/permission-templates/components/Template.js b/server/sonar-web/src/main/js/apps/permission-templates/components/Template.js index 82260039e0e..c54dfd00607 100644 --- a/server/sonar-web/src/main/js/apps/permission-templates/components/Template.js +++ b/server/sonar-web/src/main/js/apps/permission-templates/components/Template.js @@ -37,18 +37,14 @@ export default class Template extends React.PureComponent { topQualifiers: PropTypes.array.isRequired }; - constructor(props) { - super(props); - this.state = { - loading: false, - users: [], - groups: [], - query: '', - filter: 'all', - selectedPermission: null - }; - this.requestHoldersDebounced = debounce(this.requestHolders, 250); - } + state = { + loading: false, + users: [], + groups: [], + query: '', + filter: 'all', + selectedPermission: null + }; componentDidMount() { this.mounted = true; @@ -140,9 +136,7 @@ export default class Template extends React.PureComponent { handleSearch = query => { this.setState({ query }); - if (query.length === 0 || query.length > 2) { - this.requestHoldersDebounced(query); - } + this.requestHolders(query); }; handleFilter = filter => { diff --git a/server/sonar-web/src/main/js/apps/permissions/global/store/actions.js b/server/sonar-web/src/main/js/apps/permissions/global/store/actions.js index 639097378a0..82dfc9e1b67 100644 --- a/server/sonar-web/src/main/js/apps/permissions/global/store/actions.js +++ b/server/sonar-web/src/main/js/apps/permissions/global/store/actions.js @@ -87,9 +87,7 @@ export const updateQuery = (query /*: string */ = '', organization /*: ?string * dispatch /*: Dispatch */ ) => { dispatch({ type: UPDATE_QUERY, query }); - if (query.length === 0 || query.length > 2) { - dispatch(loadHolders(organization)); - } + dispatch(loadHolders(organization)); }; export const updateFilter = (filter /*: string */, organization /*: ?string */) => ( diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/App.js b/server/sonar-web/src/main/js/apps/permissions/project/components/App.js index 4ab52668b98..69f811a5f45 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/App.js +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/App.js @@ -151,11 +151,7 @@ export default class App extends React.PureComponent { handleQueryChange = (query /*: string */) => { if (this.mounted) { - this.setState({ query }, () => { - if (query.length === 0 || query.length > 2) { - this.loadHolders(); - } - }); + this.setState({ query }, this.loadHolders); } }; diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.js b/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.js index 0fdb5568931..0115c49a688 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.js +++ b/server/sonar-web/src/main/js/apps/permissions/shared/components/SearchForm.js @@ -20,79 +20,33 @@ import React from 'react'; import PropTypes from 'prop-types'; import RadioToggle from '../../../../components/controls/RadioToggle'; +import SearchBox from '../../../../components/controls/SearchBox'; import { translate, translateWithParameters } from '../../../../helpers/l10n'; -export default class SearchForm extends React.PureComponent { - static propTypes = { - query: PropTypes.string, - filter: PropTypes.oneOf(['all', 'users', 'groups']), - onSearch: PropTypes.func, - onFilter: PropTypes.func - }; - - componentWillMount() { - this.handleSubmit = this.handleSubmit.bind(this); - this.handleSearch = this.handleSearch.bind(this); - } - - handleSubmit(e) { - e.preventDefault(); - this.handleSearch(); - } - - handleSearch() { - const { value } = this.refs.searchInput; - this.props.onSearch(value); - } - - handleFilter(filter) { - this.props.onFilter(filter); - } - - render() { - const { query, filter } = this.props; - - const filterOptions = [ - { value: 'all', label: translate('all') }, - { value: 'users', label: translate('users.page') }, - { value: 'groups', label: translate('user_groups.page') } - ]; - - return ( - <div> - <RadioToggle - value={filter} - options={filterOptions} - name="users-or-groups" - onCheck={this.handleFilter.bind(this)} +export default function SearchForm(props) { + const filterOptions = [ + { value: 'all', label: translate('all') }, + { value: 'users', label: translate('users.page') }, + { value: 'groups', label: translate('user_groups.page') } + ]; + + return ( + <div className="diplay-flex-row"> + <RadioToggle + name="users-or-groups" + onCheck={props.onFilter} + options={filterOptions} + value={props.filter} + /> + + <div className="flex-1 spacer-left"> + <SearchBox + minLength={3} + onChange={props.onSearch} + placeholder={translate('search.search_for_users_or_groups')} + value={props.query} /> - - <form - className="search-box display-inline-block text-middle big-spacer-left" - onSubmit={this.handleSubmit}> - <button className="search-box-submit button-clean"> - <i className="icon-search" /> - </button> - <input - ref="searchInput" - value={query} - className="search-box-input" - style={{ width: 100 }} - type="search" - placeholder={translate('search_verb')} - onChange={this.handleSearch.bind(this)} - /> - {query.length > 0 && - query.length < 3 && ( - <div className="search-box-input-note tooltip bottom fade in"> - <div className="tooltip-inner"> - {translateWithParameters('select2.tooShort', 3)} - </div> - <div className="tooltip-arrow" style={{ left: 23 }} /> - </div> - )} - </form> </div> - ); - } + </div> + ); } diff --git a/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx index 264cb863e52..c8de3ad3906 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/PageHeader.tsx @@ -79,7 +79,6 @@ export default function PageHeader(props: Props) { )} <SearchFilterContainer - className="projects-topbar-item projects-topbar-item-search" isFavorite={props.isFavorite} organization={props.organization} query={props.query} diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageHeader-test.tsx.snap index f5561258330..71fc671f68d 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/PageHeader-test.tsx.snap @@ -17,7 +17,6 @@ exports[`should render correctly 1`] = ` view="overall" /> <SearchFilterContainer - className="projects-topbar-item projects-topbar-item-search" query={ Object { "search": "test", @@ -57,7 +56,6 @@ exports[`should render correctly while loading 1`] = ` view="overall" /> <SearchFilterContainer - className="projects-topbar-item projects-topbar-item-search" query={ Object { "search": "test", @@ -110,7 +108,6 @@ exports[`should render disabled sorting options for visualizations 1`] = ` </div> </Tooltip> <SearchFilterContainer - className="projects-topbar-item projects-topbar-item-search" query={ Object { "search": "test", diff --git a/server/sonar-web/src/main/js/apps/projects/filters/SearchFilter.tsx b/server/sonar-web/src/main/js/apps/projects/filters/SearchFilter.tsx deleted file mode 100644 index 5bc0764acdc..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/filters/SearchFilter.tsx +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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. - */ -import * as React from 'react'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -interface Props { - className?: string; - handleSearch: (userString?: string) => void; - query: { search?: string | undefined }; -} - -interface State { - userQuery?: string; -} - -export default class SearchFilter extends React.PureComponent<Props, State> { - constructor(props: Props) { - super(props); - this.state = { userQuery: props.query.search }; - } - - componentWillReceiveProps(nextProps: Props) { - if ( - this.props.query.search === this.state.userQuery && - nextProps.query.search !== this.props.query.search - ) { - this.setState({ userQuery: nextProps.query.search || '' }); - } - } - - handleQueryChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - const { value } = event.currentTarget; - this.setState({ userQuery: value }); - if (!value || value.length >= 2) { - this.props.handleSearch(value); - } - }; - - render() { - const { userQuery } = this.state; - const shortQuery = userQuery != null && userQuery.length === 1; - return ( - <div className={this.props.className}> - <input - type="search" - value={userQuery || ''} - placeholder={translate('projects.search')} - onChange={this.handleQueryChange} - autoComplete="off" - /> - {shortQuery && ( - <span className="note spacer-left">{translateWithParameters('select2.tooShort', 2)}</span> - )} - </div> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/projects/filters/SearchFilterContainer.tsx b/server/sonar-web/src/main/js/apps/projects/filters/SearchFilterContainer.tsx index b17ca9a1a21..4934f7ecaf3 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/SearchFilterContainer.tsx +++ b/server/sonar-web/src/main/js/apps/projects/filters/SearchFilterContainer.tsx @@ -19,9 +19,9 @@ */ import * as React from 'react'; import * as PropTypes from 'prop-types'; -import { debounce } from 'lodash'; import { getFilterUrl } from './utils'; -import SearchFilter from './SearchFilter'; +import SearchBox from '../../../components/controls/SearchBox'; +import { translate } from '../../../helpers/l10n'; interface Props { className?: string; @@ -35,11 +35,6 @@ export default class SearchFilterContainer extends React.PureComponent<Props> { router: PropTypes.object.isRequired }; - constructor(props: Props) { - super(props); - this.handleSearch = debounce(this.handleSearch, 250); - } - handleSearch = (userQuery?: string) => { const path = getFilterUrl(this.props, { search: userQuery }); this.context.router.push(path); @@ -47,11 +42,13 @@ export default class SearchFilterContainer extends React.PureComponent<Props> { render() { return ( - <SearchFilter - className={this.props.className} - query={this.props.query} - handleSearch={this.handleSearch} - /> + <div className="projects-topbar-item projects-topbar-item-search"> + <SearchBox + minLength={2} + onChange={this.handleSearch} + placeholder={translate('projects.search')} + /> + </div> ); } } diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilter-test.tsx b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilter-test.tsx deleted file mode 100644 index 2724f834a36..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilter-test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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. - */ -import * as React from 'react'; -import { shallow } from 'enzyme'; -import SearchFilter from '../SearchFilter'; -import { change } from '../../../../helpers/testUtils'; - -it('should render correctly without any search query', () => { - const wrapper = shallow(<SearchFilter handleSearch={jest.fn()} query={{}} />); - expect(wrapper).toMatchSnapshot(); -}); - -it('should render with a search query', () => { - const wrapper = shallow(<SearchFilter handleSearch={jest.fn()} query={{ search: 'foo' }} />); - expect(wrapper).toMatchSnapshot(); -}); - -it('should display a help message when there is less than 2 characters', () => { - const wrapper = shallow(<SearchFilter handleSearch={jest.fn()} query={{ search: 'a' }} />); - expect(wrapper).toMatchSnapshot(); - wrapper.setState({ userQuery: 'foo' }); - expect(wrapper).toMatchSnapshot(); -}); - -it('searches', () => { - const handleSearch = jest.fn(); - const wrapper = shallow(<SearchFilter handleSearch={handleSearch} query={{}} />); - - change(wrapper.find('input'), 'a'); - expect(handleSearch).not.toBeCalled(); - - change(wrapper.find('input'), 'abc'); - expect(handleSearch).toBeCalledWith('abc'); -}); - -it('updates state to new props', () => { - const wrapper = shallow(<SearchFilter handleSearch={jest.fn()} query={{ search: 'abc' }} />); - expect(wrapper.state()).toEqual({ userQuery: 'abc' }); - wrapper.setProps({ query: { search: 'def' } }); - expect(wrapper.state()).toEqual({ userQuery: 'def' }); -}); diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilterContainer-test.tsx b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilterContainer-test.tsx index 7825caf1ca2..3154a9488fd 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilterContainer-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/SearchFilterContainer-test.tsx @@ -21,17 +21,10 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import SearchFilterContainer from '../SearchFilterContainer'; -// mocking lodash, because mocking timers is now working for some reason :'( -jest.mock('lodash', () => { - const lodash = require.requireActual('lodash'); - lodash.debounce = (fn: Function) => (...args: any[]) => fn(args); - return lodash; -}); - it('searches', () => { const push = jest.fn(); const wrapper = shallow(<SearchFilterContainer query={{}} />, { context: { router: { push } } }); expect(wrapper).toMatchSnapshot(); - wrapper.prop('handleSearch')('foo'); + wrapper.find('SearchBox').prop<Function>('onChange')('foo'); expect(push).toBeCalledWith({ pathname: '/projects', query: { search: 'foo' } }); }); diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilter-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilter-test.tsx.snap deleted file mode 100644 index 86e0c761aa1..00000000000 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilter-test.tsx.snap +++ /dev/null @@ -1,54 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should display a help message when there is less than 2 characters 1`] = ` -<div> - <input - autoComplete="off" - onChange={[Function]} - placeholder="projects.search" - type="search" - value="a" - /> - <span - className="note spacer-left" - > - select2.tooShort.2 - </span> -</div> -`; - -exports[`should display a help message when there is less than 2 characters 2`] = ` -<div> - <input - autoComplete="off" - onChange={[Function]} - placeholder="projects.search" - type="search" - value="foo" - /> -</div> -`; - -exports[`should render correctly without any search query 1`] = ` -<div> - <input - autoComplete="off" - onChange={[Function]} - placeholder="projects.search" - type="search" - value="" - /> -</div> -`; - -exports[`should render with a search query 1`] = ` -<div> - <input - autoComplete="off" - onChange={[Function]} - placeholder="projects.search" - type="search" - value="foo" - /> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilterContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilterContainer-test.tsx.snap index d3d2ac3eb74..870a9ad499a 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilterContainer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/__snapshots__/SearchFilterContainer-test.tsx.snap @@ -1,8 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`searches 1`] = ` -<SearchFilter - handleSearch={[Function]} - query={Object {}} -/> +<div + className="projects-topbar-item projects-topbar-item-search" +> + <SearchBox + minLength={2} + onChange={[Function]} + placeholder="projects.search" + /> +</div> `; diff --git a/server/sonar-web/src/main/js/apps/projects/styles.css b/server/sonar-web/src/main/js/apps/projects/styles.css index 84756987169..1bd7e286548 100644 --- a/server/sonar-web/src/main/js/apps/projects/styles.css +++ b/server/sonar-web/src/main/js/apps/projects/styles.css @@ -41,19 +41,7 @@ .projects-topbar-item-search { position: relative; flex: 1; -} - -.projects-topbar-item-search input { - width: 100%; - max-width: 300px; -} - -.projects-topbar-item-search .note { - position: absolute; - top: 1px; - left: 80px; - line-height: var(--controlHeight); - pointer-events: none; + height: var(--controlHeight); } .projects-list .page-actions { diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx index 1d799214c95..67c82b0da6a 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/Search.tsx @@ -29,6 +29,7 @@ import QualifierIcon from '../../components/shared/QualifierIcon'; import Tooltip from '../../components/controls/Tooltip'; import DateInput from '../../components/controls/DateInput'; import Select from '../../components/controls/Select'; +import SearchBox from '../../components/controls/SearchBox'; export interface Props { analyzedBefore?: string; @@ -60,16 +61,6 @@ export default class Search extends React.PureComponent<Props, State> { mounted: boolean; state: State = { bulkApplyTemplateModal: false, deleteModal: false }; - onSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); - this.search(); - }; - - search = (event?: React.SyntheticEvent<HTMLInputElement>) => { - const q = event ? event.currentTarget.value : this.input.value; - this.props.onSearch(q); - }; - getQualifierOptions = () => { const options = this.props.topLevelQualifiers.map(q => ({ label: translate('qualifiers', q), @@ -206,19 +197,12 @@ export default class Search extends React.PureComponent<Props, State> { {this.renderDateFilter()} {this.renderTypeFilter()} <td className="text-middle"> - <form onSubmit={this.onSubmit} className="search-box"> - <button className="search-box-submit button-clean"> - <i className="icon-search" /> - </button> - <input - onChange={this.search} - value={this.props.query} - ref={node => (this.input = node!)} - className="search-box-input input-medium" - type="search" - placeholder={translate('search_verb')} - /> - </form> + <SearchBox + minLength={3} + onChange={this.props.onSearch} + placeholder={translate('search.search_by_name_or_key')} + value={this.props.query} + /> </td> <td className="thin nowrap text-middle"> <button diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx index 1983d99dce6..824c6ab2a5e 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/Search-test.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import Search, { Props } from '../Search'; -import { change, click } from '../../../helpers/testUtils'; +import { click } from '../../../helpers/testUtils'; const organization = { key: 'org', name: 'org', projectVisibility: 'public' }; @@ -67,7 +67,7 @@ it('updates analysis date', () => { it('searches', () => { const onSearch = jest.fn(); const wrapper = shallowRender({ onSearch }); - change(wrapper.find('input[type="search"]'), 'foo'); + wrapper.find('SearchBox').prop<Function>('onChange')('foo'); expect(onSearch).toBeCalledWith('foo'); }); diff --git a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap index 25eae9b2ece..254a3215e07 100644 --- a/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projectsManagement/__tests__/__snapshots__/Search-test.tsx.snap @@ -120,25 +120,12 @@ exports[`render qualifiers filter 1`] = ` <td className="text-middle" > - <form - className="search-box" - onSubmit={[Function]} - > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search" - /> - </button> - <input - className="search-box-input input-medium" - onChange={[Function]} - placeholder="search_verb" - type="search" - value="" - /> - </form> + <SearchBox + minLength={3} + onChange={[Function]} + placeholder="search.search_by_name_or_key" + value="" + /> </td> <td className="thin nowrap text-middle" @@ -223,25 +210,12 @@ exports[`renders 1`] = ` <td className="text-middle" > - <form - className="search-box" - onSubmit={[Function]} - > - <button - className="search-box-submit button-clean" - > - <i - className="icon-search" - /> - </button> - <input - className="search-box-input input-medium" - onChange={[Function]} - placeholder="search_verb" - type="search" - value="" - /> - </form> + <SearchBox + minLength={3} + onChange={[Function]} + placeholder="search.search_by_name_or_key" + value="" + /> </td> <td className="thin nowrap text-middle" diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js index 70b309109d0..3e6bbd28b32 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersAppContainer.js @@ -26,6 +26,7 @@ import { getCurrentUser } from '../../../store/rootReducer'; import { translate } from '../../../helpers/l10n'; // import styles to have the `.button-icon` styles import '../../../components/ui/buttons.css'; +import '../../../components/controls/SearchBox.css'; class UsersAppContainer extends React.PureComponent { static propTypes = { diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js b/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js deleted file mode 100644 index 32916a9f8ac..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSearch.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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. - */ -//@flow -import React from 'react'; -import { debounce } from 'lodash'; -import classNames from 'classnames'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; - -/*:: -type Props = { - onSearch: (query?: string) => void, - className?: string -}; -*/ - -/*:: -type State = { - query?: string -}; -*/ - -export default class UsersSearch extends React.PureComponent { - /*:: props: Props; */ - /*:: state: State; */ - - constructor(props /*: Props */) { - super(props); - this.state = { - query: '' - }; - this.handleSearch = debounce(this.handleSearch, 250); - } - - handleSearch = (query /*: string */) => { - this.props.onSearch(query); - }; - - handleInputChange = ({ target } /*: { target: HTMLInputElement } */) => { - this.setState({ query: target.value }); - if (!target.value || target.value.length >= 2) { - this.handleSearch(target.value); - } - }; - - render() { - const { query } = this.state; - const searchBoxClass = classNames('search-box', this.props.className); - const inputClassName = classNames('search-box-input', { - touched: query != null && query.length === 1 - }); - return ( - <div className={searchBoxClass}> - <button className="search-box-submit button-clean"> - <i className="icon-search" /> - </button> - <input - type="search" - value={query} - className={inputClassName} - placeholder={translate('search_verb')} - onChange={this.handleInputChange} - autoComplete="off" - /> - <span className="note spacer-left text-middle"> - {translateWithParameters('select2.tooShort', 2)} - </span> - </div> - ); - } -} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserSearch-test.js b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserSearch-test.js deleted file mode 100644 index 71fd3cc465e..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserSearch-test.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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. - */ -import React from 'react'; -import { shallow } from 'enzyme'; -import UsersSearch from '../UsersSearch'; - -it('should render correctly', () => { - const wrapper = shallow(<UsersSearch onSearch={jest.fn()} className="test" />); - expect(wrapper).toMatchSnapshot(); - wrapper.setState({ query: 'foo' }); - expect(wrapper).toMatchSnapshot(); -}); - -it('should display a help message when there is less than 2 characters', () => { - const wrapper = shallow(<UsersSearch onSearch={jest.fn()} />); - wrapper.setState({ query: 'f' }); - expect(wrapper).toMatchSnapshot(); -}); diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserSearch-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserSearch-test.js.snap deleted file mode 100644 index de9d0313c54..00000000000 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserSearch-test.js.snap +++ /dev/null @@ -1,82 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should display a help message when there is less than 2 characters 1`] = ` -<div - className="search-box" -> - <button - className="search-box-submit button-clean" - > - <i - className="icon-search" - /> - </button> - <input - autoComplete="off" - className="search-box-input touched" - onChange={[Function]} - placeholder="search_verb" - type="search" - value="f" - /> - <span - className="note spacer-left text-middle" - > - select2.tooShort.2 - </span> -</div> -`; - -exports[`should render correctly 1`] = ` -<div - className="search-box test" -> - <button - className="search-box-submit button-clean" - > - <i - className="icon-search" - /> - </button> - <input - autoComplete="off" - className="search-box-input" - onChange={[Function]} - placeholder="search_verb" - type="search" - value="" - /> - <span - className="note spacer-left text-middle" - > - select2.tooShort.2 - </span> -</div> -`; - -exports[`should render correctly 2`] = ` -<div - className="search-box test" -> - <button - className="search-box-submit button-clean" - > - <i - className="icon-search" - /> - </button> - <input - autoComplete="off" - className="search-box-input" - onChange={[Function]} - placeholder="search_verb" - type="search" - value="foo" - /> - <span - className="note spacer-left text-middle" - > - select2.tooShort.2 - </span> -</div> -`; diff --git a/server/sonar-web/src/main/js/apps/users/search-view.js b/server/sonar-web/src/main/js/apps/users/search-view.js index a0f52d20cac..f7afc9e81aa 100644 --- a/server/sonar-web/src/main/js/apps/users/search-view.js +++ b/server/sonar-web/src/main/js/apps/users/search-view.js @@ -25,13 +25,15 @@ export default Marionette.ItemView.extend({ template: Template, ui: { - hint: '.js-hint' + hint: '.js-hint', + reset: '.js-reset' }, events: { 'submit #users-search-form': 'onFormSubmit', 'search #users-search-query': 'initialOnKeyUp', - 'keyup #users-search-query': 'initialOnKeyUp' + 'keyup #users-search-query': 'initialOnKeyUp', + 'click .js-reset': 'onResetClick' }, initialize() { @@ -51,6 +53,7 @@ export default Marionette.ItemView.extend({ initialOnKeyUp() { const q = this.getQuery(); this.ui.hint.toggleClass('hidden', q.length !== 1); + this.ui.reset.toggleClass('hidden', q.length === 0); this.debouncedOnKeyUp(); }, @@ -64,6 +67,7 @@ export default Marionette.ItemView.extend({ this.searchRequest.abort(); } this.ui.hint.toggleClass('hidden', q.length !== 1); + this.ui.reset.toggleClass('hidden', q.length === 0); if (q.length !== 1) { this.searchRequest = this.search(q); } @@ -75,5 +79,14 @@ export default Marionette.ItemView.extend({ search(q) { return this.collection.fetch({ reset: true, data: { q } }); + }, + + onResetClick(e) { + e.preventDefault(); + e.currentTarget.blur(); + this.$('#users-search-query') + .val('') + .focus(); + this.onKeyUp(); } }); diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs index 4b879a050ba..a95ed5eb718 100644 --- a/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs +++ b/server/sonar-web/src/main/js/apps/users/templates/users-search.hbs @@ -1,8 +1,17 @@ <div class="panel panel-vertical bordered-bottom spacer-bottom"> <form id="users-search-form" class="search-box"> - <button id="users-search-submit" class="search-box-submit button-clean"><i class="icon-search"></i></button> - <input id="users-search-query" class="search-box-input" type="search" name="q" placeholder="{{t 'search_verb'}}" maxlength="100"> - <span class="js-hint note spacer-left text-middle hidden"> + <input id="users-search-query" class="search-box-input" type="text" name="q" placeholder="{{t 'search.search_by_login_or_name'}}" maxlength="100"> + <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> + <span class="js-hint search-box-note hidden" title="{{tp 'select2.tooShort' 2}}"> {{tp 'select2.tooShort' 2}} </span> </form> diff --git a/server/sonar-web/src/main/js/apps/web-api/components/Search.tsx b/server/sonar-web/src/main/js/apps/web-api/components/Search.tsx index ab64ad6167e..fbf984e8743 100644 --- a/server/sonar-web/src/main/js/apps/web-api/components/Search.tsx +++ b/server/sonar-web/src/main/js/apps/web-api/components/Search.tsx @@ -18,11 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { debounce } from 'lodash'; import Checkbox from '../../../components/controls/Checkbox'; import HelpIcon from '../../../components/icons-components/HelpIcon'; import Tooltip from '../../../components/controls/Tooltip'; import { translate } from '../../../helpers/l10n'; +import SearchBox from '../../../components/controls/SearchBox'; interface Props { showDeprecated: boolean; @@ -32,66 +32,38 @@ interface Props { onToggleDeprecated: () => void; } -interface State { - query: string; -} - -export default class Search extends React.PureComponent<Props, State> { - constructor(props: Props) { - super(props); - this.state = { query: '' }; - this.actuallySearch = debounce(this.actuallySearch, 250); - } - - handleSearch = (e: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ query: e.currentTarget.value }); - this.actuallySearch(); - }; - - actuallySearch = () => this.props.onSearch(this.state.query); +export default function Search(props: Props) { + const { showInternal, showDeprecated, onToggleInternal, onToggleDeprecated } = props; - render() { - const { showInternal, showDeprecated, onToggleInternal, onToggleDeprecated } = this.props; - - return ( - <div className="web-api-search"> - <div> - <i className="icon-search" /> - <input - className="spacer-left input-large" - type="search" - value={this.state.query} - placeholder={translate('search_verb')} - onChange={this.handleSearch} - /> - </div> + return ( + <div className="web-api-search"> + <div> + <SearchBox onChange={props.onSearch} placeholder={translate('api_documentation.search')} /> + </div> - <div className="big-spacer-top"> - <Checkbox checked={showInternal} onCheck={onToggleInternal}> - <span className="little-spacer-left"> - {translate('api_documentation.show_internal')} - </span> - </Checkbox> - <Tooltip overlay={translate('api_documentation.internal_tooltip')} placement="right"> - <span> - <HelpIcon className="spacer-left text-info" /> - </span> - </Tooltip> - </div> + <div className="big-spacer-top"> + <Checkbox checked={showInternal} onCheck={onToggleInternal}> + <span className="little-spacer-left">{translate('api_documentation.show_internal')}</span> + </Checkbox> + <Tooltip overlay={translate('api_documentation.internal_tooltip')} placement="right"> + <span> + <HelpIcon className="spacer-left text-info" /> + </span> + </Tooltip> + </div> - <div className="spacer-top"> - <Checkbox checked={showDeprecated} onCheck={onToggleDeprecated}> - <span className="little-spacer-left"> - {translate('api_documentation.show_deprecated')} - </span> - </Checkbox> - <Tooltip overlay={translate('api_documentation.deprecation_tooltip')} placement="right"> - <span> - <HelpIcon className="spacer-left text-info" /> - </span> - </Tooltip> - </div> + <div className="spacer-top"> + <Checkbox checked={showDeprecated} onCheck={onToggleDeprecated}> + <span className="little-spacer-left"> + {translate('api_documentation.show_deprecated')} + </span> + </Checkbox> + <Tooltip overlay={translate('api_documentation.deprecation_tooltip')} placement="right"> + <span> + <HelpIcon className="spacer-left text-info" /> + </span> + </Tooltip> </div> - ); - } + </div> + ); } diff --git a/server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Search-test.tsx.snap b/server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Search-test.tsx.snap index 2e4b787f7fe..5980b970470 100644 --- a/server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Search-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/web-api/components/__tests__/__snapshots__/Search-test.tsx.snap @@ -5,15 +5,9 @@ exports[`should render correctly 1`] = ` className="web-api-search" > <div> - <i - className="icon-search" - /> - <input - className="spacer-left input-large" + <SearchBox onChange={[Function]} - placeholder="search_verb" - type="search" - value="" + placeholder="api_documentation.search" /> </div> <div diff --git a/server/sonar-web/src/main/js/apps/web-api/styles/web-api.css b/server/sonar-web/src/main/js/apps/web-api/styles/web-api.css index 8fd5944e13e..c99b781ab07 100644 --- a/server/sonar-web/src/main/js/apps/web-api/styles/web-api.css +++ b/server/sonar-web/src/main/js/apps/web-api/styles/web-api.css @@ -9,10 +9,6 @@ white-space: nowrap; } -.web-api-search .icon-search { - color: var(--gray80); -} - .web-api-domain-header, .web-api-action-header { display: flex; 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/app/styles/components/search.css b/server/sonar-web/src/main/js/components/controls/SearchBox.css index 2eed12626d5..64951c5f907 100644 --- a/server/sonar-web/src/main/js/app/styles/components/search.css +++ b/server/sonar-web/src/main/js/components/controls/SearchBox.css @@ -19,39 +19,76 @@ */ .search-box { position: relative; + display: inline-block; + vertical-align: middle; font-size: 0; white-space: nowrap; } +.search-box, .search-box-input { - vertical-align: middle; - width: 250px; - border: none !important; - font-size: var(--baseFontSize); + width: 100%; + max-width: 300px; } -.search-box-input ~ .note { - opacity: 0; - transition: opacity 0.3s ease; +.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.touched ~ .note { +.search-box-input::placeholder { + color: var(--secondFontColor); opacity: 1; } -.search-box-submit { - display: inline-block; - vertical-align: middle; +.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-submit .icon-search:before { - color: var(--secondFontColor); - font-size: var(--mediumFontSize); +.search-box-input::-ms-clear, +.search-box-input::-ms-reveal { + display: none; + width: 0; + height: 0; } -.search-box-submit .icon-search-new { - position: relative; +.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 { 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')} diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts index 01b046039dc..8665b925032 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.ts +++ b/server/sonar-web/src/main/js/helpers/testUtils.ts @@ -56,11 +56,18 @@ export function keydown(keyCode: number): void { } export function elementKeydown(element: ShallowWrapper, keyCode: number): void { - element.simulate('keydown', { + const event = { currentTarget: { element }, keyCode, preventDefault() {} - }); + }; + + if (typeof element.type() === 'string') { + // `type()` is string for native dom elements + element.simulate('keydown', event); + } else { + element.prop<Function>('onKeyDown')(event); + } } export function doAsync(fn?: Function): Promise<void> { |