diff options
author | Stas Vilchik <stas-vilchik@users.noreply.github.com> | 2017-05-09 17:53:29 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-05-09 17:53:29 +0200 |
commit | 72e45fffdea16673f257cb80b40269e73ccffaba (patch) | |
tree | 5684e5790e20da795b6f4f10c4343e42386364fb /server/sonar-web/src/main/js | |
parent | 56fd51b9750c20b1c502789c37632d3e60012b0f (diff) | |
download | sonarqube-72e45fffdea16673f257cb80b40269e73ccffaba.tar.gz sonarqube-72e45fffdea16673f257cb80b40269e73ccffaba.zip |
MMF-661 rework search (#2030)
Diffstat (limited to 'server/sonar-web/src/main/js')
54 files changed, 1971 insertions, 591 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index c9bea5e039d..9552a20dfce 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -166,8 +166,45 @@ export function bulkChangeKey(project: string, from: string, to: string, dryRun? return postJSON(url, data); } -export const getSuggestions = (query: string): Promise<Object> => - getJSON('/api/components/suggestions', { s: query }); +export type SuggestionsResponse = { + organizations: Array<{ + key: string, + name: string + }>, + projects: Array<{ + key: string, + name: string + }>, + results: Array<{ + items: Array<{ + isFavorite: boolean, + isRecentlyBrowsed: boolean, + key: string, + match: string, + name: string, + organization: string, + project: string + }>, + more: number, + q: string + }>, + warning?: string +}; + +export const getSuggestions = ( + query: string, + recentlyBrowsed?: Array<string>, + more?: string +): Promise<SuggestionsResponse> => { + const data: Object = { s: query }; + if (recentlyBrowsed) { + data.recentlyBrowsed = recentlyBrowsed.join(); + } + if (more) { + data.more = more; + } + return getJSON('/api/components/suggestions', data); +}; export const getComponentForSourceViewer = (component: string): Promise<*> => getJSON('/api/components/app', { component }); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js b/server/sonar-web/src/main/js/app/components/RecentHistory.js index 6cd25ac4072..368eaf31748 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/RecentHistory.js +++ b/server/sonar-web/src/main/js/app/components/RecentHistory.js @@ -30,7 +30,10 @@ type History = Array<{ export default class RecentHistory { static get(): History { - let history = localStorage.getItem(STORAGE_KEY); + if (!window.localStorage) { + return []; + } + let history = window.localStorage.getItem(STORAGE_KEY); if (history == null) { history = []; } else { @@ -45,11 +48,15 @@ export default class RecentHistory { } static set(newHistory: History): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory)); + if (window.localStorage) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(newHistory)); + } } static clear(): void { - localStorage.removeItem(STORAGE_KEY); + if (window.localStorage) { + window.localStorage.removeItem(STORAGE_KEY); + } } static add( diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js index 31ed72b442e..3ee81111367 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.js @@ -22,7 +22,7 @@ import ComponentNavFavorite from './ComponentNavFavorite'; import ComponentNavBreadcrumbs from './ComponentNavBreadcrumbs'; import ComponentNavMeta from './ComponentNavMeta'; import ComponentNavMenu from './ComponentNavMenu'; -import RecentHistory from './RecentHistory'; +import RecentHistory from '../../RecentHistory'; import { TooltipsContainer } from '../../../../components/mixins/tooltips-mixin'; import { getTasksForComponent } from '../../../../api/ce'; import { STATUSES } from '../../../../apps/background-tasks/constants'; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js index 7e62d596890..033bd787f7c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js @@ -22,7 +22,7 @@ import { connect } from 'react-redux'; import GlobalNavBranding from './GlobalNavBranding'; import GlobalNavMenu from './GlobalNavMenu'; import GlobalNavUser from './GlobalNavUser'; -import GlobalNavSearch from './GlobalNavSearch'; +import GlobalNavSearchForm from './GlobalNavSearchForm'; import ShortcutsHelpView from './ShortcutsHelpView'; import { getCurrentUser, getAppState } from '../../../../store/rootReducer'; @@ -54,6 +54,7 @@ class GlobalNav extends React.PureComponent { }; render() { + /* eslint-disable max-len */ return ( <nav className="navbar navbar-global page-container" id="global-navigation"> <div className="container"> @@ -62,13 +63,20 @@ class GlobalNav extends React.PureComponent { <GlobalNavMenu {...this.props} /> <ul className="nav navbar-nav navbar-right"> - <GlobalNavUser {...this.props} /> - <GlobalNavSearch {...this.props} /> + <GlobalNavSearchForm {...this.props} /> <li> - <a onClick={this.openHelp} href="#"> - <i className="icon-help navbar-icon" /> + <a className="navbar-help" onClick={this.openHelp} href="#"> + <svg width="16" height="16"> + <g transform="matrix(0.0364583,0,0,0.0364583,1,-0.166667)"> + <path + fill="#fff" + d="M224,344L224,296C224,293.667 223.25,291.75 221.75,290.25C220.25,288.75 218.333,288 216,288L168,288C165.667,288 163.75,288.75 162.25,290.25C160.75,291.75 160,293.667 160,296L160,344C160,346.333 160.75,348.25 162.25,349.75C163.75,351.25 165.667,352 168,352L216,352C218.333,352 220.25,351.25 221.75,349.75C223.25,348.25 224,346.333 224,344ZM288,176C288,161.333 283.375,147.75 274.125,135.25C264.875,122.75 253.333,113.083 239.5,106.25C225.667,99.417 211.5,96 197,96C156.5,96 125.583,113.75 104.25,149.25C101.75,153.25 102.417,156.75 106.25,159.75L139.25,184.75C140.417,185.75 142,186.25 144,186.25C146.667,186.25 148.75,185.25 150.25,183.25C159.083,171.917 166.25,164.25 171.75,160.25C177.417,156.25 184.583,154.25 193.25,154.25C201.25,154.25 208.375,156.417 214.625,160.75C220.875,165.083 224,170 224,175.5C224,181.833 222.333,186.917 219,190.75C215.667,194.583 210,198.333 202,202C191.5,206.667 181.875,213.875 173.125,223.625C164.375,233.375 160,243.833 160,255L160,264C160,266.333 160.75,268.25 162.25,269.75C163.75,271.25 165.667,272 168,272L216,272C218.333,272 220.25,271.25 221.75,269.75C223.25,268.25 224,266.333 224,264C224,260.833 225.792,256.708 229.375,251.625C232.958,246.542 237.5,242.417 243,239.25C248.333,236.25 252.417,233.875 255.25,232.125C258.083,230.375 261.917,227.458 266.75,223.375C271.583,219.292 275.292,215.292 277.875,211.375C280.458,207.458 282.792,202.417 284.875,196.25C286.958,190.083 288,183.333 288,176ZM384,224C384,258.833 375.417,290.958 358.25,320.375C341.083,349.792 317.792,373.083 288.375,390.25C258.958,407.417 226.833,416 192,416C157.167,416 125.042,407.417 95.625,390.25C66.208,373.083 42.917,349.792 25.75,320.375C8.583,290.958 0,258.833 0,224C0,189.167 8.583,157.042 25.75,127.625C42.917,98.208 66.208,74.917 95.625,57.75C125.042,40.583 157.167,32 192,32C226.833,32 258.958,40.583 288.375,57.75C317.792,74.917 341.083,98.208 358.25,127.625C375.417,157.042 384,189.167 384,224Z" + /> + </g> + </svg> </a> </li> + <GlobalNavUser {...this.props} /> </ul> </div> </nav> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js deleted file mode 100644 index 9b40ecb2b8c..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearch.js +++ /dev/null @@ -1,116 +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 Backbone from 'backbone'; -import React from 'react'; -import { connect } from 'react-redux'; -import key from 'keymaster'; -import SearchView from './SearchView'; -import { getCurrentUser } from '../../../../store/rootReducer'; - -function contains(root, node) { - while (node) { - if (node === root) { - return true; - } - node = node.parentNode; - } - return false; -} - -class GlobalNavSearch extends React.PureComponent { - state = { open: false }; - - componentDidMount() { - key('s', () => { - const isModalOpen = document.querySelector('html').classList.contains('modal-open'); - if (!isModalOpen) { - this.openSearch(); - } - return false; - }); - } - - componentWillUnmount() { - this.closeSearch(); - key.unbind('s'); - } - - openSearch = () => { - document.addEventListener('click', this.onClickOutside); - this.setState({ open: true }, this.renderSearchView); - }; - - closeSearch = () => { - document.removeEventListener('click', this.onClickOutside); - this.resetSearchView(); - this.setState({ open: false }); - }; - - resetSearchView = () => { - if (this.searchView) { - this.searchView.destroy(); - } - }; - - onClick = e => { - e.preventDefault(); - if (this.state.open) { - this.closeSearch(); - } else { - this.openSearch(); - } - }; - - onClickOutside = e => { - if (!contains(this.refs.dropdown, e.target)) { - this.closeSearch(); - } - }; - - renderSearchView = () => { - const searchContainer = this.refs.container; - this.searchView = new SearchView({ - model: new Backbone.Model(this.props), - hide: this.closeSearch - }); - this.searchView.render().$el.appendTo(searchContainer); - }; - - render() { - const dropdownClassName = 'dropdown' + (this.state.open ? ' open' : ''); - return ( - <li ref="dropdown" className={dropdownClassName}> - <a className="navbar-search-dropdown" href="#" onClick={this.onClick}> - <i className="icon-search navbar-icon" /> <i className="icon-dropdown" /> - </a> - <div - ref="container" - className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown" - /> - </li> - ); - } -} - -const mapStateToProps = state => ({ - currentUser: getCurrentUser(state) -}); - -export default connect(mapStateToProps)(GlobalNavSearch); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js new file mode 100644 index 00000000000..469a0e673fd --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchForm.js @@ -0,0 +1,419 @@ +/* + * 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 classNames from 'classnames'; +import key from 'keymaster'; +import { debounce, groupBy, keyBy, sortBy, uniqBy } from 'lodash'; +import GlobalNavSearchFormComponent from './GlobalNavSearchFormComponent'; +import type { Component } from './GlobalNavSearchFormComponent'; +import RecentHistory from '../../RecentHistory'; +import DeferredSpinner from '../../../../components/common/DeferredSpinner'; +import { getSuggestions } from '../../../../api/components'; +import { getFavorites } from '../../../../api/favorites'; +import { translate, translateWithParameters } from '../../../../helpers/l10n'; +import { scrollToElement } from '../../../../helpers/scrolling'; +import { getProjectUrl } from '../../../../helpers/urls'; + +type Props = {| + appState: { organizationsEnabled: boolean }, + currentUser: { isLoggedIn: boolean } +|}; + +type State = { + loading: boolean, + loadingMore: ?string, + more: { [string]: number }, + open: boolean, + organizations: { [string]: { name: string } }, + projects: { [string]: { name: string } }, + query: string, + results: { [qualifier: string]: Array<Component> }, + selected: ?string, + shortQuery: boolean +}; + +const ORDER = ['DEV', 'VW', 'SVW', 'TRK', 'BRC', 'FIL', 'UTS']; + +export default class GlobalNavSearchForm extends React.PureComponent { + input: HTMLElement; + mounted: boolean; + node: HTMLElement; + nodes: { [string]: HTMLElement }; + props: Props; + state: State; + + static contextTypes = { + router: React.PropTypes.object + }; + + constructor(props: Props) { + super(props); + this.nodes = {}; + this.search = debounce(this.search, 250); + this.fetchFavoritesAndRecentlyBrowsed = debounce(this.fetchFavoritesAndRecentlyBrowsed, 250, { + leading: true + }); + this.state = { + loading: false, + loadingMore: null, + more: {}, + open: false, + organizations: {}, + projects: {}, + query: '', + results: {}, + selected: null, + shortQuery: false + }; + } + + componentDidMount() { + this.mounted = true; + key('s', () => { + this.input.focus(); + this.openSearch(); + return false; + }); + this.fetchFavoritesAndRecentlyBrowsed(); + } + + componentWillUpdate() { + this.nodes = {}; + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (prevState.selected !== this.state.selected) { + this.scrollToSelected(); + } + } + + componentWillUnmount() { + this.mounted = false; + key.unbind('s'); + window.removeEventListener('click', this.handleClickOutside); + } + + handleClickOutside = (event: { target: HTMLElement }) => { + if (!this.node || !this.node.contains(event.target)) { + this.closeSearch(); + } + }; + + openSearch = () => { + window.addEventListener('click', this.handleClickOutside); + if (!this.state.open) { + this.fetchFavoritesAndRecentlyBrowsed(); + } + this.setState({ open: true }); + }; + + closeSearch = () => { + if (this.input) { + this.input.blur(); + } + window.removeEventListener('click', this.handleClickOutside); + this.setState({ + more: {}, + open: false, + organizations: {}, + projects: {}, + query: '', + results: {}, + selected: null, + shortQuery: false + }); + }; + + getPlainComponentsList = (results: { [qualifier: string]: Array<Component> }): Array<Component> => + this.sortQualifiers(Object.keys(results)).reduce( + (components, qualifier) => [...components, ...results[qualifier]], + [] + ); + + mergeWithRecentlyBrowsed = (components: Array<Component>) => { + const recentlyBrowsed = RecentHistory.get().map(component => ({ + ...component, + isRecentlyBrowsed: true, + qualifier: component.icon.toUpperCase() + })); + return uniqBy([...components, ...recentlyBrowsed], 'key'); + }; + + fetchFavoritesAndRecentlyBrowsed = () => { + const done = (components: Array<Component>) => { + const results = groupBy(this.mergeWithRecentlyBrowsed(components), 'qualifier'); + const list = this.getPlainComponentsList(results); + this.setState({ + loading: false, + more: {}, + results, + selected: list.length > 0 ? list[0].key : null + }); + }; + + if (this.props.currentUser.isLoggedIn) { + this.setState({ loading: true }); + getFavorites().then(response => { + if (this.mounted) { + done(response.favorites.map(component => ({ ...component, isFavorite: true }))); + } + }); + } else { + done([]); + } + }; + + search = (query: string) => { + this.setState({ loading: true }); + const recentlyBrowsed = RecentHistory.get().map(component => component.key); + getSuggestions(query, recentlyBrowsed).then(response => { + if (this.mounted) { + const results = {}; + const more = {}; + response.results.forEach(group => { + results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q })); + more[group.q] = group.more; + }); + const list = this.getPlainComponentsList(results); + this.setState(state => ({ + loading: false, + more, + organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, + projects: { ...state.projects, ...keyBy(response.projects, 'key') }, + results, + selected: list.length > 0 ? list[0].key : null, + shortQuery: response.warning === 'short_input' + })); + } + }); + }; + + searchMore = (qualifier: string) => { + this.setState({ loading: true, loadingMore: qualifier }); + const recentlyBrowsed = RecentHistory.get().map(component => component.key); + getSuggestions(this.state.query, recentlyBrowsed, qualifier).then(response => { + if (this.mounted) { + const group = response.results.find(group => group.q === qualifier); + const moreResults = (group ? group.items : []).map(item => ({ ...item, qualifier })); + this.setState(state => ({ + loading: false, + loadingMore: null, + more: { ...state.more, [qualifier]: 0 }, + organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') }, + projects: { ...state.projects, ...keyBy(response.projects, 'key') }, + results: { + ...state.results, + [qualifier]: uniqBy([...state.results[qualifier], ...moreResults], 'key') + } + })); + } + }); + }; + + handleQueryChange = (event: { currentTarget: HTMLInputElement }) => { + const query = event.currentTarget.value; + this.setState({ query, shortQuery: query.length === 1 }); + if (query.length === 0) { + this.fetchFavoritesAndRecentlyBrowsed(); + } else if (query.length >= 2) { + this.search(query); + } + }; + + selectPrevious = () => { + this.setState((state: State) => { + const list = this.getPlainComponentsList(state.results); + const index = list.findIndex(component => component.key === state.selected); + return index > 0 ? { selected: list[index - 1].key } : undefined; + }); + }; + + selectNext = () => { + this.setState((state: State) => { + const list = this.getPlainComponentsList(state.results); + const index = list.findIndex(component => component.key === state.selected); + return index >= 0 && index < list.length - 1 ? { selected: list[index + 1].key } : undefined; + }); + }; + + openSelected = () => { + if (this.state.selected) { + this.context.router.push(getProjectUrl(this.state.selected)); + this.closeSearch(); + } + }; + + scrollToSelected = () => { + if (this.state.selected) { + const node = this.nodes[this.state.selected]; + if (node) { + scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node }); + } + } + }; + + handleKeyDown = (event: KeyboardEvent) => { + switch (event.keyCode) { + case 13: + event.preventDefault(); + this.openSelected(); + return; + case 27: + event.preventDefault(); + this.closeSearch(); + return; + case 38: + event.preventDefault(); + this.selectPrevious(); + return; + case 40: + event.preventDefault(); + this.selectNext(); + return; + } + }; + + handleSelect = (selected: string) => { + this.setState({ selected }); + }; + + handleMoreClick = (event: MouseEvent & { currentTarget: HTMLElement }) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.blur(); + const { qualifier } = event.currentTarget.dataset; + this.searchMore(qualifier); + }; + + sortQualifiers = (qualifiers: Array<string>) => + sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier)); + + innerRef = (component: string, node: HTMLElement) => { + this.nodes[component] = node; + }; + + renderComponent = (component: Component) => ( + <GlobalNavSearchFormComponent + appState={this.props.appState} + component={component} + innerRef={this.innerRef} + key={component.key} + onClose={this.closeSearch} + onSelect={this.handleSelect} + organizations={this.state.organizations} + projects={this.state.projects} + selected={this.state.selected === component.key} + /> + ); + + renderComponents = () => { + const qualifiers = Object.keys(this.state.results); + const renderedComponents = []; + + this.sortQualifiers(qualifiers).forEach(qualifier => { + const components = this.state.results[qualifier]; + + if (components.length > 0 && renderedComponents.length > 0) { + renderedComponents.push(<li key={`divider-${qualifier}`} className="divider" />); + } + + if (components.length > 0) { + renderedComponents.push( + <li key={`header-${qualifier}`} className="dropdown-header"> + {translate('qualifiers', qualifier)} + </li> + ); + } + + components.forEach(component => { + renderedComponents.push(this.renderComponent(component)); + }); + + const more = this.state.more[qualifier]; + if (more != null && more > 0) { + renderedComponents.push( + <li key={`more-${qualifier}`} className="menu-footer"> + <DeferredSpinner + className="navbar-search-icon" + loading={this.state.loadingMore === qualifier}> + <a data-qualifier={qualifier} href="#" onClick={this.handleMoreClick}> + {translate('show_more')} + </a> + </DeferredSpinner> + </li> + ); + } + }); + + return renderedComponents; + }; + + render() { + const dropdownClassName = classNames('dropdown', 'navbar-search', { open: this.state.open }); + + 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" + onChange={this.handleQueryChange} + onClick={event => event.stopPropagation()} + 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 + })}> + {translateWithParameters('select2.tooShort', 2)} + </span>} + + {this.state.open && + Object.keys(this.state.results).length > 0 && + <div + className="dropdown-menu dropdown-menu-right global-navbar-search-dropdown" + ref={node => (this.node = node)}> + <ul className="menu"> + {this.renderComponents()} + </ul> + <div + className="navbar-search-shortcut-hint" + dangerouslySetInnerHTML={{ + __html: translateWithParameters('search.shortcut_hint', 's') + }} + /> + </div>} + </li> + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js new file mode 100644 index 00000000000..9bf2aa3dfc5 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavSearchFormComponent.js @@ -0,0 +1,113 @@ +/* + * 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 { Link } from 'react-router'; +import FavoriteIcon from '../../../../components/common/FavoriteIcon'; +import QualifierIcon from '../../../../components/shared/QualifierIcon'; +import ClockIcon from '../../../../components/common/ClockIcon'; +import Tooltip from '../../../../components/controls/Tooltip'; +import { getProjectUrl } from '../../../../helpers/urls'; + +export type Component = { + isFavorite?: boolean, + isRecentlyBrowsed?: boolean, + key: string, + match?: string, + name: string, + organization?: string, + project?: string, + qualifier: string +}; + +type Props = {| + appState: { organizationsEnabled: boolean }, + component: Component, + innerRef: (string, HTMLElement) => void, + onClose: () => void, + onSelect: string => void, + organizations: { [string]: { name: string } }, + projects: { [string]: { name: string } }, + selected: boolean +|}; + +export default class GlobalNavSearchFormComponent extends React.PureComponent { + props: Props; + + handleMouseEnter = () => { + this.props.onSelect(this.props.component.key); + }; + + renderOrganization = (component: Component) => { + if (!this.props.appState.organizationsEnabled) { + return null; + } + + if (!['VW', 'SVW', 'TRK'].includes(component.qualifier) || component.organization == null) { + return null; + } + + const organization = this.props.organizations[component.organization]; + return organization ? <div className="pull-right text-muted-2">{organization.name}</div> : null; + }; + + renderProject = (component: Component) => { + if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) { + return null; + } + + const project = this.props.projects[component.project]; + return project ? <div className="pull-right text-muted-2">{project.name}</div> : null; + }; + + render() { + const { component } = this.props; + + return ( + <li + className={this.props.selected ? 'active' : undefined} + key={component.key} + ref={node => this.props.innerRef(component.key, node)}> + <Tooltip mouseEnterDelay={1.0} overlay={component.key} placement="left"> + <Link + data-key={component.key} + onClick={this.props.onClose} + onMouseEnter={this.handleMouseEnter} + to={getProjectUrl(component.key)}> + + {this.renderOrganization(component)} + {this.renderProject(component)} + + <span className="navbar-search-item-icons little-spacer-right"> + {component.isFavorite && <FavoriteIcon favorite={true} size={12} />} + {!component.isFavorite && component.isRecentlyBrowsed && <ClockIcon size={12} />} + <QualifierIcon className="little-spacer-right" qualifier={component.qualifier} /> + </span> + + {component.match + ? <span dangerouslySetInnerHTML={{ __html: component.match }} /> + : component.name} + + </Link> + </Tooltip> + </li> + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js index c84707129f4..8fd6fc4b8e9 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js @@ -54,9 +54,8 @@ class GlobalNavUser extends React.PureComponent { const { currentUser } = this.props; return ( <li className="dropdown js-user-authenticated"> - <a className="dropdown-toggle" data-toggle="dropdown" href="#"> - <Avatar email={currentUser.email} size={20} /> - {currentUser.name} <i className="icon-dropdown" /> + <a className="dropdown-toggle navbar-avatar" data-toggle="dropdown" href="#"> + <Avatar email={currentUser.email} name={currentUser.name} size={24} /> </a> <ul className="dropdown-menu dropdown-menu-right"> <li> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js b/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js deleted file mode 100644 index f0aecd4d887..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/SearchView.js +++ /dev/null @@ -1,303 +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 Backbone from 'backbone'; -import Marionette from 'backbone.marionette'; -import { debounce, sortBy } from 'lodash'; -import SelectableCollectionView from '../../../../components/common/selectable-collection-view'; -import SearchItemTemplate from '../templates/nav-search-item.hbs'; -import EmptySearchTemplate from '../templates/nav-search-empty.hbs'; -import SearchTemplate from '../templates/nav-search.hbs'; -import RecentHistory from '../component/RecentHistory'; -import { translate } from '../../../../helpers/l10n'; -import { isUserAdmin } from '../../../../helpers/users'; -import { getFavorites } from '../../../../api/favorites'; -import { getSuggestions } from '../../../../api/components'; -import { - getOrganization, - areThereCustomOrganizations -} from '../../../../store/organizations/utils'; - -type Finding = { - name: string, - url: string, - extra?: string -}; - -const SHOW_ORGANIZATION_FOR_QUALIFIERS = ['TRK', 'VW', 'SVW']; - -const SearchItemView = Marionette.ItemView.extend({ - tagName: 'li', - template: SearchItemTemplate, - - select() { - this.$el.addClass('active'); - }, - - deselect() { - this.$el.removeClass('active'); - }, - - submit() { - this.$('a')[0].click(); - }, - - onRender() { - this.$('[data-toggle="tooltip"]').tooltip({ - container: 'body', - html: true, - placement: 'left', - delay: { show: 500, hide: 0 } - }); - }, - - onDestroy() { - this.$('[data-toggle="tooltip"]').tooltip('destroy'); - }, - - serializeData() { - return { - ...Marionette.ItemView.prototype.serializeData.apply(this, arguments), - index: this.options.index - }; - } -}); - -const SearchEmptyView = Marionette.ItemView.extend({ - tagName: 'li', - template: EmptySearchTemplate -}); - -const SearchResultsView = SelectableCollectionView.extend({ - className: 'menu', - tagName: 'ul', - childView: SearchItemView, - emptyView: SearchEmptyView -}); - -export default Marionette.LayoutView.extend({ - className: 'navbar-search', - tagName: 'form', - template: SearchTemplate, - - regions: { - resultsRegion: '.js-search-results' - }, - - events: { - submit: 'handleSubmit', - 'keydown .js-search-input': 'onKeyDown', - 'keyup .js-search-input': 'onKeyUp' - }, - - initialize() { - this.results = new Backbone.Collection(); - this.favorite = []; - if (this.model.get('currentUser').isLoggedIn) { - this.fetchFavorite().then( - () => this.resetResultsToDefault(), - () => this.resetResultsToDefault() - ); - } else { - this.resetResultsToDefault(); - } - this.resultsView = new SearchResultsView({ collection: this.results }); - this.debouncedSearch = debounce(this.search, 250); - this._bufferedValue = ''; - }, - - onRender() { - const that = this; - this.resultsRegion.show(this.resultsView); - setTimeout(() => { - that.$('.js-search-input').focus(); - }, 0); - }, - - onKeyDown(e) { - if (e.keyCode === 38) { - this.resultsView.selectPrev(); - return false; - } - if (e.keyCode === 40) { - this.resultsView.selectNext(); - return false; - } - if (e.keyCode === 13) { - this.resultsView.submitCurrent(); - this.destroy(); - return false; - } - if (e.keyCode === 27) { - this.options.hide(); - return false; - } - }, - - onKeyUp() { - const value = this.$('.js-search-input').val(); - if (value === this._bufferedValue) { - return; - } - this._bufferedValue = this.$('.js-search-input').val(); - this.searchRequest = this.debouncedSearch(value); - }, - - onSubmit() { - return false; - }, - - fetchFavorite(): Promise<*> { - const customOrganizations = areThereCustomOrganizations(); - return getFavorites().then(r => { - this.favorite = r.favorites.map(f => { - const showOrganization = customOrganizations && f.organization != null; - const organization = showOrganization ? getOrganization(f.organization) : null; - return { - url: window.baseUrl + - '/dashboard/index?id=' + - encodeURIComponent(f.key) + - window.dashboardParameters(true), - name: f.name, - icon: 'favorite', - organization - }; - }); - this.favorite = sortBy(this.favorite, 'name'); - }); - }, - - resetResultsToDefault() { - const recentHistory = RecentHistory.get(); - const customOrganizations = areThereCustomOrganizations(); - const history = recentHistory.map((historyItem, index) => { - const url = - window.baseUrl + - '/dashboard/index?id=' + - encodeURIComponent(historyItem.key) + - window.dashboardParameters(true); - const showOrganization = customOrganizations && historyItem.organization != null; - // $FlowFixMe flow doesn't check the above condition on `historyItem.organization != null` - const organization = showOrganization ? getOrganization(historyItem.organization) : null; - return { - url, - organization, - name: historyItem.name, - q: historyItem.icon, - extra: index === 0 ? translate('browsed_recently') : null - }; - }); - const favorite = this.favorite.slice(0, 6).map((f, index) => { - return { ...f, extra: index === 0 ? translate('favorite') : null }; - }); - this.results.reset([].concat(history, favorite)); - }, - - search(q) { - if (q.length < 2) { - this.resetResultsToDefault(); - return; - } - return getSuggestions(q).then(r => { - // if the input value has changed since we sent the request, - // just ignore the output, because another request already sent - if (q !== this._bufferedValue) { - return; - } - - const customOrganizations = areThereCustomOrganizations(); - - const collection = []; - r.results.forEach(({ items, q }) => { - items.forEach((item, index) => { - const showOrganization = - customOrganizations && - item.organization != null && - SHOW_ORGANIZATION_FOR_QUALIFIERS.includes(q); - const organization = showOrganization ? getOrganization(item.organization) : null; - collection.push({ - ...item, - q, - organization, - extra: index === 0 ? translate('qualifiers', q) : null, - url: window.baseUrl + '/dashboard?id=' + encodeURIComponent(item.key) - }); - }); - }); - this.results.reset([ - ...this.getNavigationFindings(q), - ...this.getGlobalDashboardFindings(q), - ...this.getFavoriteFindings(q), - ...collection - ]); - }); - }, - - getNavigationFindings(q) { - const DEFAULT_ITEMS = [ - { name: translate('issues.page'), url: window.baseUrl + '/issues' }, - { - name: translate('layout.measures'), - url: window.baseUrl + '/measures/search?qualifiers[]=TRK' - }, - { name: translate('coding_rules.page'), url: window.baseUrl + '/coding_rules' }, - { name: translate('quality_profiles.page'), url: window.baseUrl + '/profiles' }, - { name: translate('quality_gates.page'), url: window.baseUrl + '/quality_gates' } - ]; - const customItems: Array<Finding> = []; - if (isUserAdmin(this.model.get('currentUser'))) { - customItems.push({ name: translate('layout.settings'), url: window.baseUrl + '/settings' }); - } - const findings = [].concat(DEFAULT_ITEMS, customItems).filter(f => { - return f.name.match(new RegExp(q, 'i')); - }); - if (findings.length > 0) { - findings[0].extra = translate('navigation'); - } - return findings.slice(0, 6); - }, - - getGlobalDashboardFindings(q) { - const dashboards = this.model.get('globalDashboards') || []; - const items = dashboards.map(d => { - return { - name: d.name, - url: window.baseUrl + '/dashboard/index?did=' + encodeURIComponent(d.key) - }; - }); - const findings = items.filter(f => { - return f.name.match(new RegExp(q, 'i')); - }); - if (findings.length > 0) { - findings[0].extra = translate('dashboard.global_dashboards'); - } - return findings.slice(0, 6); - }, - - getFavoriteFindings(q) { - const findings = this.favorite.filter(f => { - return f.name.match(new RegExp(q, 'i')); - }); - if (findings.length > 0) { - findings[0].extra = translate('favorite'); - } - return findings.slice(0, 6); - } -}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js new file mode 100644 index 00000000000..9ab6beaa056 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchForm-test.js @@ -0,0 +1,140 @@ +/* + * 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, mount } from 'enzyme'; +import type { ShallowWrapper } from 'enzyme'; +import GlobalNavSearchForm from '../GlobalNavSearchForm'; +import { elementKeydown, clickOutside } from '../../../../../helpers/testUtils'; + +function render(props?: Object) { + return shallow( + <GlobalNavSearchForm + appState={{ organizationsEnabled: false }} + currentUser={{ isLoggedIn: false }} + {...props} + /> + ); +} + +function component(key: string, qualifier: string = 'TRK') { + return { key, name: key, qualifier }; +} + +function next(form: ShallowWrapper, expected: string) { + elementKeydown(form.find('input'), 40); + expect(form.state().selected).toBe(expected); +} + +function prev(form: ShallowWrapper, expected: string) { + elementKeydown(form.find('input'), 38); + expect(form.state().selected).toBe(expected); +} + +function select(form: ShallowWrapper, expected: string) { + form.instance().handleSelect(expected); + expect(form.state().selected).toBe(expected); +} + +it('renders different components and dividers between them', () => { + const form = render(); + form.setState({ + open: true, + results: { + TRK: [component('foo'), component('bar')], + BRC: [component('qwe', 'BRC'), component('qux', 'BRC')], + FIL: [component('zux', 'FIL')] + } + }); + expect(form.find('.menu')).toMatchSnapshot(); +}); + +it('renders "Show More" link', () => { + const form = render(); + form.setState({ + more: { TRK: 175, BRC: 0 }, + open: true, + results: { + TRK: [component('foo'), component('bar')], + BRC: [component('qwe', 'BRC'), component('qux', 'BRC')] + } + }); + expect(form.find('.menu')).toMatchSnapshot(); +}); + +it('selects results', () => { + const form = render(); + form.setState({ + open: true, + results: { + TRK: [component('foo'), component('bar')], + BRC: [component('qwe', 'BRC')] + }, + selected: 'foo' + }); + expect(form.state().selected).toBe('foo'); + next(form, 'bar'); + next(form, 'qwe'); + next(form, 'qwe'); + prev(form, 'bar'); + select(form, 'foo'); + prev(form, 'foo'); +}); + +it('opens selected on enter', () => { + const form = render(); + form.setState({ + open: true, + results: { TRK: [component('foo')] }, + selected: 'foo' + }); + const openSelected = jest.fn(); + form.instance().openSelected = openSelected; + elementKeydown(form.find('input'), 13); + expect(openSelected).toBeCalled(); +}); + +it('shows warning about short input', () => { + const form = render(); + form.setState({ shortQuery: true }); + expect(form.find('.navbar-search-input-hint')).toMatchSnapshot(); + form.setState({ query: 'foobar x' }); + 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( + <GlobalNavSearchForm + appState={{ organizationsEnabled: false }} + currentUser={{ isLoggedIn: false }} + /> + ); + form.instance().openSearch(); + expect(form.state().open).toBe(true); + clickOutside(); + expect(form.state().open).toBe(false); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js new file mode 100644 index 00000000000..73bb358d7a6 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavSearchFormComponent-test.js @@ -0,0 +1,109 @@ +/* + * 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 { shallow } from 'enzyme'; +import GlobalNavSearchFormComponent from '../GlobalNavSearchFormComponent'; + +function render(props?: Object) { + return shallow( + // $FlowFixMe + <GlobalNavSearchFormComponent + appState={{ organizationsEnabled: false }} + component={{ key: 'foo', name: 'foo', qualifier: 'TRK', organization: 'bar' }} + innerRef={jest.fn()} + onClose={jest.fn()} + onSelect={jest.fn()} + organizations={{ bar: { name: 'bar' } }} + projects={{ foo: { name: 'foo' } }} + selected={false} + {...props} + /> + ); +} + +it('renders selected', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ selected: true }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders match', () => { + const component = { + key: 'foo', + name: 'foo', + match: 'f<mark>o</mark>o', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders favorite', () => { + const component = { + isFavorite: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders recently browsed', () => { + const component = { + isRecentlyBrowsed: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders projects', () => { + const component = { + isRecentlyBrowsed: true, + key: 'qwe', + name: 'qwe', + qualifier: 'BRC', + project: 'foo' + }; + const wrapper = render({ component }); + expect(wrapper).toMatchSnapshot(); +}); + +it('renders organizations', () => { + const component = { + isRecentlyBrowsed: true, + key: 'foo', + name: 'foo', + qualifier: 'TRK', + organization: 'bar' + }; + const wrapper = render({ appState: { organizationsEnabled: true }, component }); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ appState: { organizationsEnabled: false } }); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap new file mode 100644 index 00000000000..faf3d85dffe --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchForm-test.js.snap @@ -0,0 +1,262 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders "Show More" link 1`] = ` +<ul + className="menu" +> + <li + className="dropdown-header" + > + qualifiers.TRK + </li> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "foo", + "name": "foo", + "qualifier": "TRK", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "bar", + "name": "bar", + "qualifier": "TRK", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <li + className="menu-footer" + > + <DeferredSpinner + className="navbar-search-icon" + loading={false} + timeout={100} + > + <a + data-qualifier="TRK" + href="#" + onClick={[Function]} + > + show_more + </a> + </DeferredSpinner> + </li> + <li + className="divider" + /> + <li + className="dropdown-header" + > + qualifiers.BRC + </li> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "qwe", + "name": "qwe", + "qualifier": "BRC", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "qux", + "name": "qux", + "qualifier": "BRC", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> +</ul> +`; + +exports[`renders different components and dividers between them 1`] = ` +<ul + className="menu" +> + <li + className="dropdown-header" + > + qualifiers.TRK + </li> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "foo", + "name": "foo", + "qualifier": "TRK", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "bar", + "name": "bar", + "qualifier": "TRK", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <li + className="divider" + /> + <li + className="dropdown-header" + > + qualifiers.BRC + </li> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "qwe", + "name": "qwe", + "qualifier": "BRC", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "qux", + "name": "qux", + "qualifier": "BRC", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> + <li + className="divider" + /> + <li + className="dropdown-header" + > + qualifiers.FIL + </li> + <GlobalNavSearchFormComponent + appState={ + Object { + "organizationsEnabled": false, + } + } + component={ + Object { + "key": "zux", + "name": "zux", + "qualifier": "FIL", + } + } + innerRef={[Function]} + onClose={[Function]} + onSelect={[Function]} + organizations={Object {}} + projects={Object {}} + selected={false} + /> +</ul> +`; + +exports[`shows warning about short input 1`] = ` +<span + className="navbar-search-input-hint" +> + select2.tooShort.2 +</span> +`; + +exports[`shows warning about short input 2`] = ` +<span + className="navbar-search-input-hint is-shifted" +> + select2.tooShort.2 +</span> +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap new file mode 100644 index 00000000000..039ebbd70c1 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavSearchFormComponent-test.js.snap @@ -0,0 +1,323 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders favorite 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <FavoriteIcon + favorite={true} + size={12} + /> + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; + +exports[`renders match 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + <span + dangerouslySetInnerHTML={ + Object { + "__html": "f<mark>o</mark>o", + } + } + /> + </Link> + </Tooltip> +</li> +`; + +exports[`renders organizations 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <div + className="pull-right text-muted-2" + > + bar + </div> + <span + className="navbar-search-item-icons little-spacer-right" + > + <ClockIcon + size={12} + /> + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; + +exports[`renders organizations 2`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <ClockIcon + size={12} + /> + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; + +exports[`renders projects 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="qwe" + placement="left" + > + <Link + data-key="qwe" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "qwe", + }, + } + } + > + <div + className="pull-right text-muted-2" + > + foo + </div> + <span + className="navbar-search-item-icons little-spacer-right" + > + <ClockIcon + size={12} + /> + <QualifierIcon + className="little-spacer-right" + qualifier="BRC" + /> + </span> + qwe + </Link> + </Tooltip> +</li> +`; + +exports[`renders recently browsed 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <ClockIcon + size={12} + /> + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; + +exports[`renders selected 1`] = ` +<li> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; + +exports[`renders selected 2`] = ` +<li + className="active" +> + <Tooltip + mouseEnterDelay={1} + overlay="foo" + placement="left" + > + <Link + data-key="foo" + onClick={[Function]} + onMouseEnter={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to={ + Object { + "pathname": "/dashboard", + "query": Object { + "id": "foo", + }, + } + } + > + <span + className="navbar-search-item-icons little-spacer-right" + > + <QualifierIcon + className="little-spacer-right" + qualifier="TRK" + /> + </span> + foo + </Link> + </Tooltip> +</li> +`; diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs deleted file mode 100644 index fb76e686612..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-empty.hbs +++ /dev/null @@ -1 +0,0 @@ -<span class="note">{{t 'no_results'}}</span> diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs deleted file mode 100644 index 185169fdbda..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search-item.hbs +++ /dev/null @@ -1,28 +0,0 @@ -{{#notNull extra}} - {{#gt index 0}} - <div class="divider"></div> - {{/gt}} - {{#if extra}} - <div class="dropdown-header">{{extra}}</div> - {{/if}} -{{/notNull}} - -<a href="{{this.url}}" data-title="{{name}}<br>{{key}}" data-toggle="tooltip"> - {{#if organization}} - <div class="pull-right nowrap note"> - {{organization.name}} - </div> - {{/if}} - - {{#if icon}}<i class="icon-{{icon}} text-text-bottom"></i>{{/if}} - {{#if q}}{{qualifierIcon q}}{{/if}} - {{#eq q 'FIL'}} - {{collapsedDirFromPath name}}{{fileFromPath name}} - {{else}} - {{#eq q 'UTS'}} - {{collapsedDirFromPath name}}{{fileFromPath name}} - {{else}} - {{name}} - {{/eq}} - {{/eq}} -</a> diff --git a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs b/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs deleted file mode 100644 index 68e1f3ad168..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/templates/nav-search.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<i class="navbar-search-icon icon-search"></i> - -<input class="navbar-search-input js-search-input" type="search" name="q" placeholder="{{t 'search_verb'}}" - maxlength="30" autocomplete="off"> - -<div class="js-search-results"></div> - -<div class="note navbar-search-subtitle">{{t 'search.shortcut'}}</div> diff --git a/server/sonar-web/src/main/js/app/styles/boxed-group.css b/server/sonar-web/src/main/js/app/styles/boxed-group.css index 3b3836cb6cc..c1f569c1206 100644 --- a/server/sonar-web/src/main/js/app/styles/boxed-group.css +++ b/server/sonar-web/src/main/js/app/styles/boxed-group.css @@ -26,16 +26,11 @@ line-height: 24px; } -.boxed-group-header > [class^="icon-"] { +.boxed-group-header [class^="icon-"] { display: inline-block; vertical-align: middle; } -.boxed-group-header > .icon-star { - position: relative; - top: 1px; -} - .boxed-group-actions { float: right; margin-top: 15px; diff --git a/server/sonar-web/src/main/js/apps/account/components/UserCard.js b/server/sonar-web/src/main/js/apps/account/components/UserCard.js index 0659fc188e1..f1076d9894a 100644 --- a/server/sonar-web/src/main/js/apps/account/components/UserCard.js +++ b/server/sonar-web/src/main/js/apps/account/components/UserCard.js @@ -31,7 +31,7 @@ export default class UserCard extends React.PureComponent { return ( <div className="account-user"> <div id="avatar" className="pull-left account-user-avatar"> - <Avatar email={user.email} size={60} /> + <Avatar email={user.email} name={user.name} size={60} /> </div> <h1 id="name" className="pull-left">{user.name}</h1> </div> diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js index 8ec80944e7c..f9854fb1cce 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js @@ -256,6 +256,7 @@ export default class BulkChangeModal extends React.PureComponent { className="little-spacer-right" email={option.email} hash={option.avatar} + name={option.label} size={16} />} {option.label} diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js index 09609eabe9d..752666243da 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js @@ -95,6 +95,7 @@ export default class AssigneeFacet extends React.PureComponent { <Avatar className="little-spacer-right" hash={referencedUsers[assignee].avatar} + name={referencedUsers[assignee].name} size={16} /> {referencedUsers[assignee].name} @@ -115,7 +116,12 @@ export default class AssigneeFacet extends React.PureComponent { return ( <span> {option.avatar != null && - <Avatar className="little-spacer-right" hash={option.avatar} size={16} />} + <Avatar + className="little-spacer-right" + hash={option.avatar} + name={option.label} + size={16} + />} {option.label} </span> ); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap index 4f476fe87e1..f09143436c7 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.js.snap @@ -32,6 +32,7 @@ exports[`should render 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="avatart-foo" + name="name-foo" size={16} /> name-foo @@ -65,6 +66,7 @@ exports[`should render footer select option 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="avatar-foo" + name="name-foo" size={16} /> name-foo @@ -117,6 +119,7 @@ exports[`should select unassigned 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="avatart-foo" + name="name-foo" size={16} /> name-foo @@ -177,6 +180,7 @@ exports[`should select user 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="avatart-foo" + name="name-foo" size={16} /> name-foo diff --git a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js index df9a1496503..ff7971ee825 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/MembersListItem.js @@ -45,7 +45,7 @@ export default class MembersListItem extends React.PureComponent { return ( <tr> <td className="thin nowrap"> - <Avatar hash={member.avatar} email={member.email} size={AVATAR_SIZE} /> + <Avatar hash={member.avatar} email={member.email} name={member.name} size={AVATAR_SIZE} /> </td> <td className="nowrap text-middle"> <strong>{member.name}</strong> diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap index 7d5d1fd90e6..a5635313371 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/MembersListItem-test.js.snap @@ -7,6 +7,7 @@ exports[`should groups at 0 if the groupCount field is not defined (just added u > <Connect(Avatar) hash="7daf6c79d4802916d83f6266e24850af" + name="John Doe" size={36} /> </td> @@ -101,6 +102,7 @@ exports[`should not render actions and groups for non admin 1`] = ` > <Connect(Avatar) hash="" + name="Admin Istrator" size={36} /> </td> @@ -126,6 +128,7 @@ exports[`should render actions and groups for admin 1`] = ` > <Connect(Avatar) hash="" + name="Admin Istrator" size={36} /> </td> diff --git a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js b/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js index 18a8a125aa8..8883bbddb9f 100644 --- a/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js +++ b/server/sonar-web/src/main/js/apps/permissions/shared/components/UserHolder.js @@ -59,7 +59,12 @@ export default class UserHolder extends React.PureComponent { <tr> <td className="nowrap"> {!isCreator && - <Avatar email={user.email} size={36} className="text-middle big-spacer-right" />} + <Avatar + email={user.email} + name={user.name} + size={36} + className="text-middle big-spacer-right" + />} <div className="display-inline-block text-middle"> <div> <strong>{user.name}</strong> diff --git a/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js b/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js index 34f9228be1e..90af5ae2f54 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js +++ b/server/sonar-web/src/main/js/apps/project-admin/key/BulkUpdate.js @@ -30,7 +30,7 @@ import { closeAllGlobalMessages } from '../../../store/globalMessages/duck'; import { reloadUpdateKeyPage } from './utils'; -import RecentHistory from '../../../app/components/nav/component/RecentHistory'; +import RecentHistory from '../../../app/components/RecentHistory'; class BulkUpdate extends React.PureComponent { static propTypes = { diff --git a/server/sonar-web/src/main/js/apps/project-admin/key/Key.js b/server/sonar-web/src/main/js/apps/project-admin/key/Key.js index 9ba721d3308..b3f47fc3785 100644 --- a/server/sonar-web/src/main/js/apps/project-admin/key/Key.js +++ b/server/sonar-web/src/main/js/apps/project-admin/key/Key.js @@ -32,7 +32,7 @@ import { } from '../../../store/globalMessages/duck'; import { parseError } from '../../code/utils'; import { reloadUpdateKeyPage } from './utils'; -import RecentHistory from '../../../app/components/nav/component/RecentHistory'; +import RecentHistory from '../../../app/components/RecentHistory'; import { getProjectAdminProjectModules, getComponent } from '../../../store/rootReducer'; class Key extends React.PureComponent { diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Logout.js b/server/sonar-web/src/main/js/apps/sessions/components/Logout.js index 1ff047bb5f0..736c4cc6372 100644 --- a/server/sonar-web/src/main/js/apps/sessions/components/Logout.js +++ b/server/sonar-web/src/main/js/apps/sessions/components/Logout.js @@ -23,7 +23,7 @@ import { connect } from 'react-redux'; import GlobalMessagesContainer from '../../../app/components/GlobalMessagesContainer'; import { doLogout } from '../../../store/rootActions'; import { translate } from '../../../helpers/l10n'; -import RecentHistory from '../../../app/components/nav/component/RecentHistory'; +import RecentHistory from '../../../app/components/RecentHistory'; class Logout extends React.PureComponent { componentDidMount() { diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js index 125a20a5f0e..e7f4a5ab428 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchOption.js @@ -63,7 +63,7 @@ export default class UsersSelectSearchOption extends React.PureComponent { onMouseMove={this.handleMouseMove} title={user.name}> <div className="little-spacer-bottom little-spacer-top"> - <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> + <Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} /> <strong className="spacer-left">{this.props.children}</strong> <span className="note little-spacer-left">{user.login}</span> </div> diff --git a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js index 19ad6ab9029..090ad3a0d22 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js +++ b/server/sonar-web/src/main/js/apps/users/components/UsersSelectSearchValue.js @@ -39,7 +39,7 @@ export default class UsersSelectSearchValue extends React.PureComponent { {user && user.login && <div className="Select-value-label"> - <Avatar hash={user.avatar} email={user.email} size={AVATAR_SIZE} /> + <Avatar hash={user.avatar} email={user.email} name={user.name} size={AVATAR_SIZE} /> <strong className="spacer-left">{this.props.children}</strong> <span className="note little-spacer-left">{user.login}</span> </div>} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap index 45aa90aba39..80c53be30fe 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchOption-test.js.snap @@ -12,6 +12,7 @@ exports[`should render correctly with email instead of hash 1`] = ` > <Connect(Avatar) email="admin@admin.ch" + name="Administrator" size={20} /> <strong @@ -40,6 +41,7 @@ exports[`should render correctly without all parameters 1`] = ` > <Connect(Avatar) hash="7daf6c79d4802916d83f6266e24850af" + name="Administrator" size={20} /> <strong diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap index e6523c5d2e6..7771b377fb2 100644 --- a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UsersSelectSearchValue-test.js.snap @@ -10,6 +10,7 @@ exports[`should render correctly with a user 1`] = ` > <Connect(Avatar) hash="7daf6c79d4802916d83f6266e24850af" + name="Administrator" size={20} /> <strong @@ -36,6 +37,7 @@ exports[`should render correctly with email instead of hash 1`] = ` > <Connect(Avatar) email="admin@admin.ch" + name="Administrator" size={20} /> <strong diff --git a/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs b/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs index 45e51c4983c..9e73394a88c 100644 --- a/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs +++ b/server/sonar-web/src/main/js/apps/users/templates/users-list-item.hbs @@ -1,8 +1,6 @@ -{{#ifShowAvatars}} - <td class="thin nowrap"> - <div>{{avatarHelper email 36}}</div> - </td> -{{/ifShowAvatars}} +<td class="thin nowrap"> + <div>{{avatarHelper email name 36}}</div> +</td> <td> <div> diff --git a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js b/server/sonar-web/src/main/js/components/common/ClockIcon.js index 2b65dd68060..8e3a2cecfa8 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelperNew.js +++ b/server/sonar-web/src/main/js/components/common/ClockIcon.js @@ -17,20 +17,31 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import Handlebars from 'handlebars/runtime'; +// @flow +import React from 'react'; +import classNames from 'classnames'; -function gravatarServer() { - const getStore = require('../../app/utils/getStore').default; - const { getSettingValue } = require('../../store/rootReducer'); +type Props = { + className?: string, + size?: number +}; - const store = getStore(); - return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value; +export default function ClockIcon(props: Props) { + /* eslint max-len: 0 */ + return ( + <svg + className={classNames('icon-clock', props.className)} + viewBox="0 0 16 16" + width={props.size} + height={props.size}> + <g fill="#fff" stroke="#ADADAD" transform="matrix(1.4 0 0 1.4 .3 .7)"> + <circle cx="5.5" cy="5.2" r="5" /> + <path fillRule="nonzero" d="M5.6 2.9v2.7l2-.5" /> + </g> + </svg> + ); } -module.exports = function(emailHash, size) { - // double the size for high pixel density screens - const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2); - return new Handlebars.default.SafeString( - `<img class="rounded" src="${url}" width="${size}" height="${size}" alt="">` - ); +ClockIcon.defaultProps = { + size: 16 }; diff --git a/server/sonar-web/src/main/js/components/common/DeferredSpinner.js b/server/sonar-web/src/main/js/components/common/DeferredSpinner.js new file mode 100644 index 00000000000..070f705f7be --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/DeferredSpinner.js @@ -0,0 +1,83 @@ +/* + * 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 classNames from 'classnames'; + +type Props = { + children?: React.Element<*>, + className?: string, + loading?: boolean, + timeout: number +}; + +type State = { + showSpinner: boolean +}; + +export default class DeferredSpinner extends React.PureComponent { + props: Props; + state: State; + timer: number; + + static defaultProps = { + timeout: 100 + }; + + constructor(props: Props) { + super(props); + this.state = { showSpinner: false }; + } + + componentDidMount() { + if (this.props.loading == null || this.props.loading === true) { + this.startTimer(); + } + } + + componentWillReceiveProps(nextProps: Props) { + if (this.props.loading === false && nextProps.loading === true) { + this.stopTimer(); + this.startTimer(); + } + if (this.props.loading === true && nextProps.loading === false) { + this.stopTimer(); + this.setState({ showSpinner: false }); + } + } + + componentWillUnmount() { + this.stopTimer(); + } + + startTimer = () => { + this.timer = setTimeout(() => this.setState({ showSpinner: true }), this.props.timeout); + }; + + stopTimer = () => { + clearTimeout(this.timer); + }; + + render() { + return this.state.showSpinner + ? <i className={classNames('spinner', this.props.className)} /> + : this.props.children || null; + } +} diff --git a/server/sonar-web/src/main/js/components/common/FavoriteIcon.js b/server/sonar-web/src/main/js/components/common/FavoriteIcon.js new file mode 100644 index 00000000000..98157aa9e5d --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/FavoriteIcon.js @@ -0,0 +1,44 @@ +/* + * 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 classNames from 'classnames'; + +type Props = { + className?: string, + favorite: boolean, + size?: number +}; + +export default function FavoriteIcon(props: Props) { + /* eslint max-len: 0 */ + return ( + <span + className={classNames('icon-star', { 'icon-star-favorite': props.favorite }, props.className)}> + <svg width={props.size} height={props.size} viewBox="0 0 16 16"> + <path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> + </svg> + </span> + ); +} + +FavoriteIcon.defaultProps = { + size: 16 +}; diff --git a/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js b/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js new file mode 100644 index 00000000000..62d755cc949 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/DeferredSpinner-test.js @@ -0,0 +1,56 @@ +/* + * 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 { mount } from 'enzyme'; +import DeferredSpinner from '../DeferredSpinner'; + +jest.useFakeTimers(); + +it('renders spinner after timeout', () => { + const spinner = mount(<DeferredSpinner />); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('add custom className', () => { + const spinner = mount(<DeferredSpinner className="foo" />); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('renders children before timeout', () => { + const spinner = mount(<DeferredSpinner><div>foo</div></DeferredSpinner>); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); +}); + +it('is controlled by loading prop', () => { + const spinner = mount(<DeferredSpinner loading={false}><div>foo</div></DeferredSpinner>); + expect(spinner).toMatchSnapshot(); + spinner.setProps({ loading: true }); + expect(spinner).toMatchSnapshot(); + jest.runAllTimers(); + expect(spinner).toMatchSnapshot(); + spinner.setProps({ loading: false }); + expect(spinner).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap new file mode 100644 index 00000000000..51d17f504c7 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/DeferredSpinner-test.js.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`add custom className 1`] = ` +<DeferredSpinner + className="foo" + timeout={100} +> + <i + className="spinner foo" + /> +</DeferredSpinner> +`; + +exports[`is controlled by loading prop 1`] = ` +<DeferredSpinner + loading={false} + timeout={100} +> + <div> + foo + </div> +</DeferredSpinner> +`; + +exports[`is controlled by loading prop 2`] = ` +<DeferredSpinner + loading={true} + timeout={100} +> + <div> + foo + </div> +</DeferredSpinner> +`; + +exports[`is controlled by loading prop 3`] = ` +<DeferredSpinner + loading={true} + timeout={100} +> + <i + className="spinner" + /> +</DeferredSpinner> +`; + +exports[`is controlled by loading prop 4`] = ` +<DeferredSpinner + loading={false} + timeout={100} +> + <div> + foo + </div> +</DeferredSpinner> +`; + +exports[`renders children before timeout 1`] = ` +<DeferredSpinner + timeout={100} +> + <div> + foo + </div> +</DeferredSpinner> +`; + +exports[`renders children before timeout 2`] = ` +<DeferredSpinner + timeout={100} +> + <i + className="spinner" + /> +</DeferredSpinner> +`; + +exports[`renders spinner after timeout 1`] = ` +<DeferredSpinner + timeout={100} +/> +`; + +exports[`renders spinner after timeout 2`] = ` +<DeferredSpinner + timeout={100} +> + <i + className="spinner" + /> +</DeferredSpinner> +`; diff --git a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js index 648fe040bc0..b417b22e48a 100644 --- a/server/sonar-web/src/main/js/components/controls/FavoriteBase.js +++ b/server/sonar-web/src/main/js/components/controls/FavoriteBase.js @@ -19,6 +19,7 @@ */ import React from 'react'; import classNames from 'classnames'; +import FavoriteIcon from '../common/FavoriteIcon'; export default class FavoriteBase extends React.PureComponent { static propTypes = { @@ -67,27 +68,13 @@ export default class FavoriteBase extends React.PureComponent { }); } - renderSVG() { - /* eslint max-len: 0 */ - return ( - <svg width="16" height="16"> - <path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> - </svg> - ); - } - render() { - const className = classNames( - 'icon-star', - { - 'icon-star-favorite': this.state.favorite - }, - this.props.className - ); - return ( - <a className={className} href="#" onClick={this.toggleFavorite}> - {this.renderSVG()} + <a + className={classNames('link-no-underline', this.props.className)} + href="#" + onClick={this.toggleFavorite}> + <FavoriteIcon favorite={this.state.favorite} /> </a> ); } diff --git a/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js b/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js index b59055482bd..1e4f325fa1f 100644 --- a/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js +++ b/server/sonar-web/src/main/js/components/controls/FavoriteBaseStateless.js @@ -19,6 +19,7 @@ */ import React from 'react'; import classNames from 'classnames'; +import FavoriteIcon from '../common/FavoriteIcon'; export default class FavoriteBaseStateless extends React.PureComponent { static propTypes = { @@ -37,27 +38,13 @@ export default class FavoriteBaseStateless extends React.PureComponent { } }; - renderSVG() { - /* eslint max-len: 0 */ - return ( - <svg width="16" height="16"> - <path d="M15.4275,5.77678C15.4275,5.90773 15.3501,6.05059 15.1953,6.20536L11.9542,9.36608L12.7221,13.8304C12.728,13.872 12.731,13.9316 12.731,14.0089C12.731,14.1339 12.6998,14.2396 12.6373,14.3259C12.5748,14.4122 12.484,14.4554 12.3649,14.4554C12.2518,14.4554 12.1328,14.4197 12.0078,14.3482L7.99888,12.2411L3.98995,14.3482C3.85901,14.4197 3.73996,14.4554 3.63281,14.4554C3.50781,14.4554 3.41406,14.4122 3.35156,14.3259C3.28906,14.2396 3.25781,14.1339 3.25781,14.0089C3.25781,13.9732 3.26377,13.9137 3.27567,13.8304L4.04353,9.36608L0.793531,6.20536C0.644719,6.04464 0.570313,5.90178 0.570313,5.77678C0.570313,5.55654 0.736979,5.41964 1.07031,5.36606L5.55245,4.71428L7.56138,0.651781C7.67447,0.407729 7.8203,0.285703 7.99888,0.285703C8.17745,0.285703 8.32328,0.407729 8.43638,0.651781L10.4453,4.71428L14.9274,5.36606C15.2608,5.41964 15.4274,5.55654 15.4274,5.77678L15.4275,5.77678Z" /> - </svg> - ); - } - render() { - const className = classNames( - 'icon-star', - { - 'icon-star-favorite': this.props.favorite - }, - this.props.className - ); - return ( - <a className={className} href="#" onClick={this.toggleFavorite}> - {this.renderSVG()} + <a + className={classNames('link-no-underline', this.props.className)} + href="#" + onClick={this.toggleFavorite}> + <FavoriteIcon favorite={this.props.favorite} /> </a> ); } diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js b/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js index a22b38993eb..86ab63dbc6c 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js +++ b/server/sonar-web/src/main/js/components/controls/__tests__/FavoriteBase-test.js @@ -30,12 +30,12 @@ function renderFavoriteBase(props) { it('should render favorite', () => { const favorite = renderFavoriteBase({ favorite: true }); - expect(favorite.is('.icon-star-favorite')).toBe(true); + expect(favorite).toMatchSnapshot(); }); it('should render not favorite', () => { const favorite = renderFavoriteBase({ favorite: false }); - expect(favorite.is('.icon-star-favorite')).toBe(false); + expect(favorite).toMatchSnapshot(); }); it('should add favorite', () => { diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap new file mode 100644 index 00000000000..c7cccad562c --- /dev/null +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/FavoriteBase-test.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render favorite 1`] = ` +<a + className="link-no-underline" + href="#" + onClick={[Function]} +> + <FavoriteIcon + favorite={true} + size={16} + /> +</a> +`; + +exports[`should render not favorite 1`] = ` +<a + className="link-no-underline" + href="#" + onClick={[Function]} +> + <FavoriteIcon + favorite={false} + size={16} + /> +</a> +`; diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js index 55c84072696..b8b6aaa77fe 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueAssign.js @@ -47,7 +47,12 @@ export default class IssueAssign extends React.PureComponent { <span> {issue.assignee && <span className="text-top"> - <Avatar className="little-spacer-right" hash={issue.assigneeAvatar} size={16} /> + <Avatar + className="little-spacer-right" + hash={issue.assigneeAvatar} + name={issue.assigneeName} + size={16} + /> </span>} <span className="issue-meta-label"> {issue.assignee ? issue.assigneeName : translate('unassigned')} diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js index c82e8c4a0d9..aa37ea0873b 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.js @@ -72,7 +72,12 @@ export default class IssueCommentLine extends React.PureComponent { return ( <div className="issue-comment"> <div className="issue-comment-author" title={comment.authorName}> - <Avatar className="little-spacer-right" hash={comment.authorAvatar} size={16} /> + <Avatar + className="little-spacer-right" + hash={comment.authorAvatar} + name={comment.authorName} + size={16} + /> {comment.authorName} </div> <div diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap index ea1ebdc57d3..5823525789c 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueAssign-test.js.snap @@ -48,6 +48,7 @@ exports[`should open the popup when the button is clicked 2`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> </span> @@ -94,6 +95,7 @@ exports[`should render with the action 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> </span> @@ -118,6 +120,7 @@ exports[`should render without the action when the correct rights are missing 1` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> </span> diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap index fa3f4afc205..c98cf19fd04 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueCommentLine-test.js.snap @@ -23,6 +23,7 @@ exports[`should open the right popups when the buttons are clicked 3`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> John Doe @@ -117,6 +118,7 @@ exports[`should render correctly a comment that is not updatable 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> John Doe @@ -153,6 +155,7 @@ exports[`should render correctly a comment that is updatable 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="John Doe" size={16} /> John Doe diff --git a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js index 501f4b2b612..c856a2c94c7 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.js @@ -94,9 +94,12 @@ export default class ChangelogPopup extends React.PureComponent { {moment(item.creationDate).format('LLL')} </td> <td className="thin text-left text-top nowrap"> - {item.userName && - item.avatar && - <Avatar className="little-spacer-right" hash={item.avatar} size={16} />} + <Avatar + className="little-spacer-right" + hash={item.avatar} + name={item.userName} + size={16} + /> {item.userName} </td> <td className="text-left text-top"> 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 933a43c818e..25c1f31a5e8 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 @@ -141,13 +141,13 @@ export default class SetAssigneePopup extends React.PureComponent { onSelect={this.props.onSelect}> {this.state.users.map(user => ( <SelectListItem key={user.login} item={user.login}> - {(user.avatar || user.email) && - <Avatar - className="spacer-right" - email={user.email} - hash={user.avatar} - size={16} - />} + <Avatar + className="spacer-right" + email={user.email} + hash={user.avatar} + name={user.name} + size={16} + /> <span className="vertical-middle" style={{ marginLeft: !user.avatar && !user.email ? 24 : undefined }}> diff --git a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js index 88d352e9950..3281838da05 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.js @@ -96,6 +96,7 @@ export default class SimilarIssuesPopup extends React.PureComponent { <Avatar className="little-spacer-left little-spacer-right" hash={issue.assigneeAvatar} + name={issue.assigneeName} size={16} /> {issue.assigneeName} diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap index 8dc76c9e817..8934f554418 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/ChangelogPopup-test.js.snap @@ -38,6 +38,7 @@ exports[`should render the changelog popup correctly 1`] = ` <Connect(Avatar) className="little-spacer-right" hash="gravatarhash" + name="john.doe" size={16} /> john.doe diff --git a/server/sonar-web/src/main/js/components/ui/Avatar.js b/server/sonar-web/src/main/js/components/ui/Avatar.js index b17ce477592..5938676d917 100644 --- a/server/sonar-web/src/main/js/components/ui/Avatar.js +++ b/server/sonar-web/src/main/js/components/ui/Avatar.js @@ -23,19 +23,74 @@ import md5 from 'blueimp-md5'; import classNames from 'classnames'; import { getSettingValue } from '../../store/rootReducer'; +function stringToColor(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += ('00' + value.toString(16)).substr(-2); + } + return color; +} + +function getTextColor(background) { + const rgb = parseInt(background.substr(1), 16); + const r = (rgb >> 16) & 0xff; + const g = (rgb >> 8) & 0xff; + const b = (rgb >> 0) & 0xff; + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luma > 140 ? '#222' : '#fff'; +} + class Avatar extends React.PureComponent { static propTypes = { enableGravatar: React.PropTypes.bool.isRequired, gravatarServerUrl: React.PropTypes.string.isRequired, email: React.PropTypes.string, hash: React.PropTypes.string, + name: React.PropTypes.string.isRequired, size: React.PropTypes.number.isRequired, className: React.PropTypes.string }; + renderFallback() { + const className = classNames(this.props.className, 'rounded'); + const color = stringToColor(this.props.name); + + let text = ''; + const words = this.props.name.split(/\s+/).filter(word => word.length > 0); + if (words.length >= 2) { + text = words[0][0] + words[1][0]; + } else if (this.props.name.length > 0) { + text = this.props.name[0]; + } + + return ( + <div + className={className} + style={{ + backgroundColor: color, + color: getTextColor(color), + display: 'inline-block', + fontSize: Math.min(this.props.size / 2, 14), + fontWeight: 'normal', + height: this.props.size, + lineHeight: `${this.props.size}px`, + textAlign: 'center', + verticalAlign: 'top', + width: this.props.size + }}> + {text.toUpperCase()} + </div> + ); + } + render() { if (!this.props.enableGravatar) { - return null; + return this.renderFallback(); } const emailHash = this.props.hash || md5.md5((this.props.email || '').toLowerCase()).trim(); diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js b/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js index d52a47ad4ac..1cc40347fd8 100644 --- a/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js +++ b/server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.js @@ -29,26 +29,11 @@ it('should render', () => { enableGravatar={true} gravatarServerUrl={gravatarServerUrl} email="mail@example.com" + name="Foo" size={20} /> ); - expect(avatar.is('img')).toBe(true); - expect(avatar.prop('width')).toBe(20); - expect(avatar.prop('height')).toBe(20); - expect(avatar.prop('alt')).toBe('mail@example.com'); - expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40'); -}); - -it('should not render', () => { - const avatar = shallow( - <Avatar - enableGravatar={false} - gravatarServerUrl={gravatarServerUrl} - email="mail@example.com" - size={20} - /> - ); - expect(avatar.is('img')).toBe(false); + expect(avatar).toMatchSnapshot(); }); it('should be able to render with hash only', () => { @@ -57,12 +42,16 @@ it('should be able to render with hash only', () => { enableGravatar={true} gravatarServerUrl={gravatarServerUrl} hash="7daf6c79d4802916d83f6266e24850af" + name="Foo" size={30} /> ); - expect(avatar.is('img')).toBe(true); - expect(avatar.prop('width')).toBe(30); - expect(avatar.prop('height')).toBe(30); - expect(avatar.prop('alt')).toBeUndefined(); - expect(avatar.prop('src')).toBe('http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60'); + expect(avatar).toMatchSnapshot(); +}); + +it('falls back to dummy avatar', () => { + const avatar = shallow( + <Avatar enableGravatar={false} gravatarServerUrl="" name="Foo Bar" size={30} /> + ); + expect(avatar).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap new file mode 100644 index 00000000000..d51b5ecabcb --- /dev/null +++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`falls back to dummy avatar 1`] = ` +<div + className="rounded" + style={ + Object { + "backgroundColor": "#79e189", + "color": "#222", + "display": "inline-block", + "fontSize": 14, + "fontWeight": "normal", + "height": 30, + "lineHeight": "30px", + "textAlign": "center", + "verticalAlign": "top", + "width": 30, + } + } +> + FB +</div> +`; + +exports[`should be able to render with hash only 1`] = ` +<img + className="rounded" + height={30} + src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60" + width={30} +/> +`; + +exports[`should render 1`] = ` +<img + alt="mail@example.com" + className="rounded" + height={20} + src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=40" + width={20} +/> +`; diff --git a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js b/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js index 67220a97d5a..ec05453594f 100644 --- a/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js +++ b/server/sonar-web/src/main/js/helpers/handlebars/avatarHelper.js @@ -17,22 +17,18 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import md5 from 'blueimp-md5'; +import React from 'react'; +import { renderToString } from 'react-dom/server'; import Handlebars from 'handlebars/runtime'; +import WithStore from '../../components/shared/WithStore'; +import Avatar from '../../components/ui/Avatar'; -function gravatarServer() { - const getStore = require('../../app/utils/getStore').default; - const { getSettingValue } = require('../../store/rootReducer'); - - const store = getStore(); - return (getSettingValue(store.getState(), 'sonar.lf.gravatarServerUrl') || {}).value; -} - -module.exports = function(email, size) { - // double the size for high pixel density screens - const emailHash = md5.md5((email || '').trim()); - const url = gravatarServer().replace('{EMAIL_MD5}', emailHash).replace('{SIZE}', size * 2); +module.exports = function(email, name, size) { return new Handlebars.default.SafeString( - `<img class="rounded" src="${url}" width="${size}" height="${size}" alt="${email}">` + renderToString( + <WithStore> + <Avatar email={email} name={name} size={size} /> + </WithStore> + ) ); }; diff --git a/server/sonar-web/src/main/js/helpers/testUtils.js b/server/sonar-web/src/main/js/helpers/testUtils.js index 96b718cc64b..4ae17931866 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.js +++ b/server/sonar-web/src/main/js/helpers/testUtils.js @@ -26,6 +26,11 @@ export const mockEvent = { export const click = (element, event = {}) => element.simulate('click', { ...mockEvent, ...event }); +export const clickOutside = (event = {}) => { + const dispatchedEvent = new MouseEvent('click', event); + window.dispatchEvent(dispatchedEvent); +}; + export const submit = element => element.simulate('submit', { preventDefault() {} @@ -41,3 +46,11 @@ export const keydown = keyCode => { const event = new KeyboardEvent('keydown', { keyCode }); document.dispatchEvent(event); }; + +export const elementKeydown = (element, keyCode) => { + element.simulate('keydown', { + currentTarget: { element }, + keyCode, + preventDefault() {} + }); +}; |