Browse Source

rewrite global search in ts (#680)

tags/7.5
Stas Vilchik 5 years ago
parent
commit
a52c25d808
18 changed files with 556 additions and 390 deletions
  1. 15
    18
      server/sonar-web/src/main/js/app/components/RecentHistory.ts
  2. 103
    0
      server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx
  3. 2
    2
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
  4. 0
    28
      server/sonar-web/src/main/js/app/components/search/Search.d.ts
  5. 107
    108
      server/sonar-web/src/main/js/app/components/search/Search.tsx
  6. 28
    34
      server/sonar-web/src/main/js/app/components/search/SearchResult.tsx
  7. 0
    87
      server/sonar-web/src/main/js/app/components/search/SearchResults.js
  8. 79
    0
      server/sonar-web/src/main/js/app/components/search/SearchResults.tsx
  9. 19
    20
      server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx
  10. 38
    38
      server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx
  11. 24
    26
      server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx
  12. 23
    8
      server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx
  13. 61
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx
  14. 0
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap
  15. 0
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap
  16. 6
    1
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap
  17. 34
    0
      server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap
  18. 17
    20
      server/sonar-web/src/main/js/app/components/search/utils.ts

server/sonar-web/src/main/js/app/components/RecentHistory.js → server/sonar-web/src/main/js/app/components/RecentHistory.ts View File

@@ -17,50 +17,47 @@
* 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 { get, remove, save } from '../../helpers/storage';

const RECENT_HISTORY = 'sonar_recent_history';
const HISTORY_LIMIT = 10;

/*::
type History = Array<{
key: string,
name: string,
icon: string,
organization?: string
export type History = Array<{
key: string;
name: string;
icon: string;
organization?: string;
}>;
*/

export default class RecentHistory {
static get() /*: History */ {
static get(): History {
const history = get(RECENT_HISTORY);
if (history == null) {
return [];
} else {
try {
return JSON.parse(history);
} catch (e) {
} catch {
remove(RECENT_HISTORY);
return [];
}
}
}

static set(newHistory /*: History */) /*: void */ {
static set(newHistory: History) {
save(RECENT_HISTORY, JSON.stringify(newHistory));
}

static clear() /*: void */ {
static clear() {
remove(RECENT_HISTORY);
}

static add(
componentKey /*: string */,
componentName /*: string */,
icon /*: string */,
organization /*: string | void */
) /*: void */ {
componentKey: string,
componentName: string,
icon: string,
organization: string | undefined
) {
const sonarHistory = RecentHistory.get();
const newEntry = { key: componentKey, name: componentName, icon, organization };
let newHistory = sonarHistory.filter(entry => entry.key !== newEntry.key);
@@ -69,7 +66,7 @@ export default class RecentHistory {
RecentHistory.set(newHistory);
}

static remove(componentKey /*: string */) /*: void */ {
static remove(componentKey: string) {
const history = RecentHistory.get();
const newHistory = history.filter(entry => entry.key !== componentKey);
RecentHistory.set(newHistory);

+ 103
- 0
server/sonar-web/src/main/js/app/components/__tests__/RecentHistory-test.tsx View File

@@ -0,0 +1,103 @@
/*
* SonarQube
* Copyright (C) 2009-2018 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 RecentHistory, { History } from '../RecentHistory';
import { get, remove, save } from '../../../helpers/storage';

jest.mock('../../../helpers/storage', () => ({
get: jest.fn(),
remove: jest.fn(),
save: jest.fn()
}));

beforeEach(() => {
(get as jest.Mock).mockClear();
(remove as jest.Mock).mockClear();
(save as jest.Mock).mockClear();
});

it('should get existing history', () => {
const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }];
(get as jest.Mock).mockReturnValueOnce(JSON.stringify(history));
expect(RecentHistory.get()).toEqual(history);
expect(get).toBeCalledWith('sonar_recent_history');
});

it('should get empty history', () => {
(get as jest.Mock).mockReturnValueOnce(null);
expect(RecentHistory.get()).toEqual([]);
expect(get).toBeCalledWith('sonar_recent_history');
});

it('should return [] and clear history in case of failure', () => {
(get as jest.Mock).mockReturnValueOnce('not a json');
expect(RecentHistory.get()).toEqual([]);
expect(get).toBeCalledWith('sonar_recent_history');
expect(remove).toBeCalledWith('sonar_recent_history');
});

it('should save history', () => {
const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }];
RecentHistory.set(history);
expect(save).toBeCalledWith('sonar_recent_history', JSON.stringify(history));
});

it('should clear history', () => {
RecentHistory.clear();
expect(remove).toBeCalledWith('sonar_recent_history');
});

it('should add item to history', () => {
const history = [{ key: 'foo', name: 'Foo', icon: 'TRK' }];
(get as jest.Mock).mockReturnValueOnce(JSON.stringify(history));
RecentHistory.add('bar', 'Bar', 'VW', 'org');
expect(save).toBeCalledWith(
'sonar_recent_history',
JSON.stringify([{ key: 'bar', name: 'Bar', icon: 'VW', organization: 'org' }, ...history])
);
});

it('should keep 10 items maximum', () => {
const history: History = [];
for (let i = 0; i < 10; i++) {
history.push({ key: `key-${i}`, name: `name-${i}`, icon: 'TRK' });
}
(get as jest.Mock).mockReturnValueOnce(JSON.stringify(history));
RecentHistory.add('bar', 'Bar', 'VW', 'org');
expect(save).toBeCalledWith(
'sonar_recent_history',
JSON.stringify([
{ key: 'bar', name: 'Bar', icon: 'VW', organization: 'org' },
...history.slice(0, 9)
])
);
});

it('should remove component from history', () => {
const history: History = [];
for (let i = 0; i < 10; i++) {
history.push({ key: `key-${i}`, name: `name-${i}`, icon: 'TRK' });
}
(get as jest.Mock).mockReturnValueOnce(JSON.stringify(history));
RecentHistory.remove('key-5');
expect(save).toBeCalledWith(
'sonar_recent_history',
JSON.stringify([...history.slice(0, 5), ...history.slice(6)])
);
});

+ 2
- 2
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap View File

@@ -47,7 +47,7 @@ exports[`should render for SonarCloud 1`] = `
suggestions={Array []}
tooltip={false}
/>
<Search
<withRouter(Search)
appState={
Object {
"canAdmin": false,
@@ -127,7 +127,7 @@ exports[`should render for SonarQube 1`] = `
suggestions={Array []}
tooltip={true}
/>
<Search
<withRouter(Search)
appState={
Object {
"canAdmin": false,

+ 0
- 28
server/sonar-web/src/main/js/app/components/search/Search.d.ts View File

@@ -1,28 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { CurrentUser, AppState } from '../../types';

export interface Props {
appState: Pick<AppState, 'organizationsEnabled'>;
currentUser: CurrentUser;
}

export default class Search extends React.PureComponent<Props> {}

server/sonar-web/src/main/js/app/components/search/Search.js → server/sonar-web/src/main/js/app/components/search/Search.tsx View File

@@ -17,14 +17,12 @@
* 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 PropTypes from 'prop-types';
import key from 'keymaster';
import * as React from 'react';
import * as key from 'keymaster';
import { debounce, keyBy, uniqBy } from 'lodash';
import { FormattedMessage } from 'react-intl';
import { sortQualifiers } from './utils';
/*:: import type { Component, More, Results } from './utils'; */
import { withRouter, WithRouterProps } from 'react-router';
import { sortQualifiers, More, Results, ComponentResult } from './utils';
import RecentHistory from '../RecentHistory';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { DropdownOverlay } from '../../../components/controls/Dropdown';
@@ -36,60 +34,50 @@ import { getSuggestions } from '../../../api/components';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { scrollToElement } from '../../../helpers/scrolling';
import { getProjectUrl } from '../../../helpers/urls';
import { AppState, CurrentUser } from '../../types';
import './Search.css';

const SearchResults = lazyLoad(() => import('./SearchResults'));
const SearchResult = lazyLoad(() => import('./SearchResult'));

/*::
type Props = {|
appState: { organizationsEnabled: boolean },
currentUser: { isLoggedIn: boolean }
|};
*/

/*::
type State = {
loading: boolean,
loadingMore: ?string,
more: More,
open: boolean,
organizations: { [string]: { name: string } },
projects: { [string]: { name: string } },
query: string,
results: Results,
selected: ?string,
shortQuery: boolean
};
*/

export default class Search extends React.PureComponent {
/*:: input: HTMLInputElement | null; */
/*:: mounted: boolean; */
/*:: node: HTMLElement; */
/*:: nodes: { [string]: HTMLElement };
*/
/*:: props: Props; */
/*:: state: State; */

static contextTypes = {
router: PropTypes.object
};
interface OwnProps {
appState: Pick<AppState, 'organizationsEnabled'>;
currentUser: CurrentUser;
}

type Props = OwnProps & WithRouterProps;

interface State {
loading: boolean;
loadingMore?: string;
more: More;
open: boolean;
organizations: { [key: string]: { name: string } };
projects: { [key: string]: { name: string } };
query: string;
results: Results;
selected?: string;
shortQuery: boolean;
}

constructor(props /*: Props */) {
export class Search extends React.PureComponent<Props, State> {
input?: HTMLInputElement | null;
node?: HTMLElement | null;
nodes: { [x: string]: HTMLElement };
mounted = false;

constructor(props: Props) {
super(props);
this.nodes = {};
this.search = debounce(this.search, 250);
this.state = {
loading: false,
loadingMore: null,
more: {},
open: false,
organizations: {},
projects: {},
query: '',
results: {},
selected: null,
shortQuery: false
};
}
@@ -97,9 +85,7 @@ export default class Search extends React.PureComponent {
componentDidMount() {
this.mounted = true;
key('s', () => {
if (this.input) {
this.input.focus();
}
this.focusInput();
this.openSearch();
return false;
});
@@ -109,7 +95,7 @@ export default class Search extends React.PureComponent {
this.nodes = {};
}

componentDidUpdate(prevProps /*: Props */, prevState /*: State */) {
componentDidUpdate(_prevProps: Props, prevState: State) {
if (prevState.selected !== this.state.selected) {
this.scrollToSelected();
}
@@ -120,6 +106,12 @@ export default class Search extends React.PureComponent {
key.unbind('s');
}

focusInput = () => {
if (this.input) {
this.input.focus();
}
};

handleClickOutside = () => {
this.closeSearch(false);
};
@@ -142,29 +134,27 @@ export default class Search extends React.PureComponent {
this.setState({ open: true });
};

closeSearch = (clear /*: boolean */ = true) => {
closeSearch = (clear = true) => {
if (this.input) {
this.input.blur();
}
this.setState(
clear
? {
more: {},
open: false,
organizations: {},
projects: {},
query: '',
results: {},
selected: null,
shortQuery: false
}
: {
open: false
}
);
if (clear) {
this.setState({
more: {},
open: false,
organizations: {},
projects: {},
query: '',
results: {},
selected: undefined,
shortQuery: false
});
} else {
this.setState({ open: false });
}
};

getPlainComponentsList = (results /*: Results */, more /*: More */) =>
getPlainComponentsList = (results: Results, more: More) =>
sortQualifiers(Object.keys(results)).reduce((components, qualifier) => {
const next = [...components, ...results[qualifier].map(component => component.key)];
if (more[qualifier]) {
@@ -173,7 +163,7 @@ export default class Search extends React.PureComponent {
return next;
}, []);

mergeWithRecentlyBrowsed = (components /*: Array<Component> */) => {
mergeWithRecentlyBrowsed = (components: ComponentResult[]) => {
const recentlyBrowsed = RecentHistory.get().map(component => ({
...component,
isRecentlyBrowsed: true,
@@ -188,7 +178,7 @@ export default class Search extends React.PureComponent {
}
};

search = (query /*: string */) => {
search = (query: string) => {
if (query.length === 0 || query.length >= 2) {
this.setState({ loading: true });
const recentlyBrowsed = RecentHistory.get().map(component => component.key);
@@ -196,8 +186,8 @@ export default class Search extends React.PureComponent {
// compare `this.state.query` and `query` to handle two request done almost at the same time
// in this case only the request that matches the current query should be taken
if (this.mounted && this.state.query === query) {
const results = {};
const more = {};
const results: Results = {};
const more: More = {};
response.results.forEach(group => {
results[group.q] = group.items.map(item => ({ ...item, qualifier: group.q }));
more[group.q] = group.more;
@@ -209,7 +199,7 @@ export default class Search extends React.PureComponent {
organizations: { ...state.organizations, ...keyBy(response.organizations, 'key') },
projects: { ...state.projects, ...keyBy(response.projects, 'key') },
results,
selected: list.length > 0 ? list[0] : null,
selected: list.length > 0 ? list[0] : undefined,
shortQuery: query.length > 2 && response.warning === 'short_input'
}));
}
@@ -219,55 +209,60 @@ export default class Search extends React.PureComponent {
}
};

searchMore = (qualifier /*: string */) => {
if (this.state.query.length !== 1) {
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')
},
selected: moreResults.length > 0 ? moreResults[0].key : state.selected
}));
if (this.input) {
this.input.focus();
}
}
}, this.stopLoading);
searchMore = (qualifier: string) => {
const { query } = this.state;
if (query.length === 1) {
return;
}

this.setState({ loading: true, loadingMore: qualifier });
const recentlyBrowsed = RecentHistory.get().map(component => component.key);
getSuggestions(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: undefined,
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')
},
selected: moreResults.length > 0 ? moreResults[0].key : state.selected
}));
this.focusInput();
}
}, this.stopLoading);
};

handleQueryChange = (query /*: string */) => {
handleQueryChange = (query: string) => {
this.setState({ query, shortQuery: query.length === 1 });
this.search(query);
};

selectPrevious = () => {
this.setState(({ more, results, selected } /*: State */) => {
this.setState(({ more, results, selected }) => {
if (selected) {
const list = this.getPlainComponentsList(results, more);
const index = list.indexOf(selected);
return index > 0 ? { selected: list[index - 1] } : undefined;
return index > 0 ? { selected: list[index - 1] } : null;
} else {
return null;
}
});
};

selectNext = () => {
this.setState(({ more, results, selected } /*: State */) => {
this.setState(({ more, results, selected }) => {
if (selected) {
const list = this.getPlainComponentsList(results, more);
const index = list.indexOf(selected);
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : undefined;
return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null;
} else {
return null;
}
});
};
@@ -278,7 +273,7 @@ export default class Search extends React.PureComponent {
if (selected.startsWith('qualifier###')) {
this.searchMore(selected.substr(12));
} else {
this.context.router.push(getProjectUrl(selected));
this.props.router.push(getProjectUrl(selected));
this.closeSearch();
}
}
@@ -287,13 +282,13 @@ export default class Search extends React.PureComponent {
scrollToSelected = () => {
if (this.state.selected) {
const node = this.nodes[this.state.selected];
if (node) {
if (node && this.node) {
scrollToElement(node, { topOffset: 30, bottomOffset: 30, parent: this.node });
}
}
};

handleKeyDown = (event /*: KeyboardEvent */) => {
handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.keyCode) {
case 13:
event.preventDefault();
@@ -312,19 +307,21 @@ export default class Search extends React.PureComponent {
}
};

handleSelect = (selected /*: string */) => {
handleSelect = (selected: string) => {
this.setState({ selected });
};

innerRef = (component /*: string */, node /*: HTMLElement */) => {
this.nodes[component] = node;
innerRef = (component: string, node: HTMLElement | null) => {
if (node) {
this.nodes[component] = node;
}
};

searchInputRef = (node /*: HTMLInputElement | null */) => {
searchInputRef = (node: HTMLInputElement | null) => {
this.input = node;
};

renderResult = (component /*: Component */) => (
renderResult = (component: ComponentResult) => (
<SearchResult
appState={this.props.appState}
component={component}
@@ -407,3 +404,5 @@ export default class Search extends React.PureComponent {
);
}
}

export default withRouter<OwnProps>(Search);

server/sonar-web/src/main/js/app/components/search/SearchResult.js → server/sonar-web/src/main/js/app/components/search/SearchResult.tsx View File

@@ -17,41 +17,36 @@
* 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 * as React from 'react';
import { Link } from 'react-router';
/*:: import type { Component } from './utils'; */
import { ComponentResult } from './utils';
import FavoriteIcon from '../../../components/icons-components/FavoriteIcon';
import QualifierIcon from '../../../components/icons-components/QualifierIcon';
import ClockIcon from '../../../components/icons-components/ClockIcon';
import Tooltip from '../../../components/controls/Tooltip';
import { getProjectUrl } from '../../../helpers/urls';
import { AppState } from '../../types';

interface Props {
appState: Pick<AppState, 'organizationsEnabled'>;
component: ComponentResult;
innerRef: (componentKey: string, node: HTMLElement | null) => void;
onClose: () => void;
onSelect: (componentKey: string) => void;
organizations: { [key: string]: { name: string } };
projects: { [key: string]: { name: string } };
selected: boolean;
}

/*::
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
|};
*/

/*::
type State = {
tooltipVisible: boolean
};
*/
interface State {
tooltipVisible: boolean;
}

const TOOLTIP_DELAY = 1000;

export default class SearchResult extends React.PureComponent {
/*:: interval: ?number; */
/*:: props: Props; */
state /*: State */ = { tooltipVisible: false };
export default class SearchResult extends React.PureComponent<Props, State> {
interval?: number;
state: State = { tooltipVisible: false };

componentDidMount() {
if (this.props.selected) {
@@ -59,7 +54,7 @@ export default class SearchResult extends React.PureComponent {
}
}

componentWillReceiveProps(nextProps /*: Props */) {
componentWillReceiveProps(nextProps: Props) {
if (!this.props.selected && nextProps.selected) {
this.scheduleTooltip();
} else if (this.props.selected && !nextProps.selected) {
@@ -73,12 +68,14 @@ export default class SearchResult extends React.PureComponent {
}

scheduleTooltip = () => {
this.interval = setTimeout(() => this.setState({ tooltipVisible: true }), TOOLTIP_DELAY);
this.interval = window.setTimeout(() => {
this.setState({ tooltipVisible: true });
}, TOOLTIP_DELAY);
};

unscheduleTooltip = () => {
if (this.interval) {
clearInterval(this.interval);
window.clearInterval(this.interval);
}
};

@@ -86,15 +83,12 @@ export default class SearchResult extends React.PureComponent {
this.props.onSelect(this.props.component.key);
};

renderOrganization = (component /*: Component */) => {
renderOrganization = (component: ComponentResult) => {
if (!this.props.appState.organizationsEnabled) {
return null;
}

if (
!['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) ||
component.organization == null
) {
if (!['VW', 'SVW', 'APP', 'TRK'].includes(component.qualifier) || !component.organization) {
return null;
}

@@ -104,7 +98,7 @@ export default class SearchResult extends React.PureComponent {
) : null;
};

renderProject = (component /*: Component */) => {
renderProject = (component: ComponentResult) => {
if (!['BRC', 'FIL', 'UTS'].includes(component.qualifier) || component.project == null) {
return null;
}

+ 0
- 87
server/sonar-web/src/main/js/app/components/search/SearchResults.js View File

@@ -1,87 +0,0 @@
/*
* SonarQube
* Copyright (C) 2009-2018 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 SearchShowMore from './SearchShowMore';
import { sortQualifiers } from './utils';
/*:: import type { Component, More, Results } from './utils'; */
import { translate } from '../../../helpers/l10n';

/*::
type Props = {|
allowMore: boolean,
loadingMore: ?string,
more: More,
onMoreClick: string => void,
onSelect: string => void,
renderNoResults: () => React.Element<*>,
renderResult: Component => React.Element<*>,
results: Results,
selected: ?string
|};
*/

export default class SearchResults extends React.PureComponent {
/*:: props: Props; */

render() {
const qualifiers = Object.keys(this.props.results);
const renderedComponents = [];

sortQualifiers(qualifiers).forEach(qualifier => {
const components = this.props.results[qualifier];

if (components.length > 0 && renderedComponents.length > 0) {
renderedComponents.push(<li className="divider" key={`divider-${qualifier}`} />);
}

if (components.length > 0) {
renderedComponents.push(
<li className="menu-header" key={`header-${qualifier}`}>
{translate('qualifiers', qualifier)}
</li>
);
}

components.forEach(component => renderedComponents.push(this.props.renderResult(component)));

const more = this.props.more[qualifier];
if (more != null && more > 0) {
renderedComponents.push(
<SearchShowMore
allowMore={this.props.allowMore}
key={`more-${qualifier}`}
loadingMore={this.props.loadingMore}
onMoreClick={this.props.onMoreClick}
onSelect={this.props.onSelect}
qualifier={qualifier}
selected={this.props.selected === `qualifier###${qualifier}`}
/>
);
}
});

return renderedComponents.length > 0 ? (
<ul className="menu">{renderedComponents}</ul>
) : (
this.props.renderNoResults()
);
}
}

+ 79
- 0
server/sonar-web/src/main/js/app/components/search/SearchResults.tsx View File

@@ -0,0 +1,79 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import SearchShowMore from './SearchShowMore';
import { sortQualifiers, More, ComponentResult, Results } from './utils';
import { translate } from '../../../helpers/l10n';

export interface Props {
allowMore: boolean;
loadingMore?: string;
more: More;
onMoreClick: (qualifier: string) => void;
onSelect: (componentKey: string) => void;
renderNoResults: () => React.ReactElement<any>;
renderResult: (component: ComponentResult) => React.ReactNode;
results: Results;
selected?: string;
}

export default function SearchResults(props: Props): React.ReactElement<Props> {
const qualifiers = Object.keys(props.results);
const renderedComponents: React.ReactNode[] = [];

sortQualifiers(qualifiers).forEach(qualifier => {
const components = props.results[qualifier];

if (components.length > 0 && renderedComponents.length > 0) {
renderedComponents.push(<li className="divider" key={`divider-${qualifier}`} />);
}

if (components.length > 0) {
renderedComponents.push(
<li className="menu-header" key={`header-${qualifier}`}>
{translate('qualifiers', qualifier)}
</li>
);
}

components.forEach(component => renderedComponents.push(props.renderResult(component)));

const more = props.more[qualifier];
if (more !== undefined && more > 0) {
renderedComponents.push(
<SearchShowMore
allowMore={props.allowMore}
key={`more-${qualifier}`}
loadingMore={props.loadingMore}
onMoreClick={props.onMoreClick}
onSelect={props.onSelect}
qualifier={qualifier}
selected={props.selected === `qualifier###${qualifier}`}
/>
);
}
});

return renderedComponents.length > 0 ? (
<ul className="menu">{renderedComponents}</ul>
) : (
props.renderNoResults()
);
}

server/sonar-web/src/main/js/app/components/search/SearchShowMore.js → server/sonar-web/src/main/js/app/components/search/SearchShowMore.tsx View File

@@ -17,37 +17,36 @@
* 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 * as React from 'react';
import * as classNames from 'classnames';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import { translate, translateWithParameters } from '../../../helpers/l10n';

/*::
type Props = {|
allowMore: boolean,
loadingMore: ?string,
onMoreClick: string => void,
onSelect: string => void,
qualifier: string,
selected: boolean
|};
*/

export default class SearchShowMore extends React.PureComponent {
/*:: props: Props; */
interface Props {
allowMore: boolean;
loadingMore?: string;
onMoreClick: (qualifier: string) => void;
onSelect: (qualifier: string) => void;
qualifier: string;
selected: boolean;
}

handleMoreClick = (event /*: MouseEvent & { currentTarget: HTMLElement } */) => {
export default class SearchShowMore extends React.PureComponent<Props> {
handleMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.blur();
const { qualifier } = event.currentTarget.dataset;
this.props.onMoreClick(qualifier);
if (qualifier) {
this.props.onMoreClick(qualifier);
}
};

handleMoreMouseEnter = (event /*: { currentTarget: HTMLElement } */) => {
handleMoreMouseEnter = (event: React.MouseEvent<HTMLAnchorElement>) => {
const { qualifier } = event.currentTarget.dataset;
this.props.onSelect(`qualifier###${qualifier}`);
if (qualifier) {
this.props.onSelect(`qualifier###${qualifier}`);
}
};

render() {

server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.js → server/sonar-web/src/main/js/app/components/search/__tests__/Search-test.tsx View File

@@ -17,43 +17,13 @@
* 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 Search from '../Search';
import { elementKeydown, clickOutside } from '../../../../helpers/testUtils';

function render(props /*: ?Object */) {
return shallow(
<Search
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('SearchBox'), 40);
expect(form.state().selected).toBe(expected);
}

function prev(form /*: ShallowWrapper */, expected /*: string */) {
elementKeydown(form.find('SearchBox'), 38);
expect(form.state().selected).toBe(expected);
}

function select(form /*: ShallowWrapper */, expected /*: string */) {
form.instance().handleSelect(expected);
expect(form.state().selected).toBe(expected);
}
import * as React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { Search } from '../Search';
import { elementKeydown } from '../../../../helpers/testUtils';

it('selects results', () => {
const form = render();
const form = shallowRender();
form.setState({
more: { TRK: 15, BRC: 0 },
open: true,
@@ -75,22 +45,52 @@ it('selects results', () => {
});

it('opens selected on enter', () => {
const form = render();
const form = shallowRender();
form.setState({
open: true,
results: { TRK: [component('foo')] },
selected: 'foo'
});
const openSelected = jest.fn();
form.instance().openSelected = openSelected;
(form.instance() as Search).openSelected = openSelected;
elementKeydown(form.find('SearchBox'), 13);
expect(openSelected).toBeCalled();
});

it('shows warning about short input', () => {
const form = render();
const form = shallowRender();
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();
});

function shallowRender(props: Partial<Search['props']> = {}) {
return shallow(
// @ts-ignore
<Search
appState={{ organizationsEnabled: false }}
currentUser={{ isLoggedIn: false }}
{...props}
/>
);
}

function component(key: string, qualifier = 'TRK') {
return { key, name: key, qualifier };
}

function next(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
elementKeydown(form.find('SearchBox'), 40);
expect(form.state().selected).toBe(expected);
}

function prev(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
elementKeydown(form.find('SearchBox'), 38);
expect(form.state().selected).toBe(expected);
}

function select(form: ShallowWrapper<Search['props'], Search['state']>, expected: string) {
(form.instance() as Search).handleSelect(expected);
expect(form.state().selected).toBe(expected);
}

server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.js → server/sonar-web/src/main/js/app/components/search/__tests__/SearchResult-test.tsx View File

@@ -17,32 +17,14 @@
* 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 * as React from 'react';
import { shallow } from 'enzyme';
import SearchResult from '../SearchResult';

function render(props /*: ?Object */) {
return shallow(
// $FlowFixMe
<SearchResult
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}
/>
);
}

jest.useFakeTimers();

it('renders selected', () => {
const wrapper = render();
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ selected: true });
expect(wrapper).toMatchSnapshot();
@@ -56,7 +38,7 @@ it('renders match', () => {
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
const wrapper = shallowRender({ component });
expect(wrapper).toMatchSnapshot();
});

@@ -68,7 +50,7 @@ it('renders favorite', () => {
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
const wrapper = shallowRender({ component });
expect(wrapper).toMatchSnapshot();
});

@@ -80,7 +62,7 @@ it('renders recently browsed', () => {
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ component });
const wrapper = shallowRender({ component });
expect(wrapper).toMatchSnapshot();
});

@@ -92,7 +74,7 @@ it('renders projects', () => {
qualifier: 'BRC',
project: 'foo'
};
const wrapper = render({ component });
const wrapper = shallowRender({ component });
expect(wrapper).toMatchSnapshot();
});

@@ -104,14 +86,14 @@ it('renders organizations', () => {
qualifier: 'TRK',
organization: 'bar'
};
const wrapper = render({ appState: { organizationsEnabled: true }, component });
const wrapper = shallowRender({ appState: { organizationsEnabled: true }, component });
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ appState: { organizationsEnabled: false } });
expect(wrapper).toMatchSnapshot();
});

it('shows tooltip after delay', () => {
const wrapper = render();
const wrapper = shallowRender();
expect(wrapper.find('Tooltip').prop('visible')).toBe(false);

wrapper.setProps({ selected: true });
@@ -124,3 +106,19 @@ it('shows tooltip after delay', () => {
wrapper.setProps({ selected: false });
expect(wrapper.find('Tooltip').prop('visible')).toBe(false);
});

function shallowRender(props: Partial<SearchResult['props']> = {}) {
return shallow(
<SearchResult
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}
/>
);
}

server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.js → server/sonar-web/src/main/js/app/components/search/__tests__/SearchResults-test.tsx View File

@@ -17,17 +17,15 @@
* 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 * as React from 'react';
import { shallow } from 'enzyme';
import SearchResults from '../SearchResults';
import SearchResults, { Props } from '../SearchResults';

it('renders different components and dividers between them', () => {
expect(
shallow(
<SearchResults
allowMore={true}
loadingMore={null}
more={{}}
onMoreClick={jest.fn()}
onSelect={jest.fn()}
@@ -38,7 +36,6 @@ it('renders different components and dividers between them', () => {
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')],
FIL: [component('zux', 'FIL')]
}}
selected={null}
/>
)
).toMatchSnapshot();
@@ -49,7 +46,6 @@ it('renders "Show More" link', () => {
shallow(
<SearchResults
allowMore={true}
loadingMore={null}
more={{ TRK: 175, BRC: 0 }}
onMoreClick={jest.fn()}
onSelect={jest.fn()}
@@ -59,12 +55,31 @@ it('renders "Show More" link', () => {
TRK: [component('foo'), component('bar')],
BRC: [component('qwe', 'BRC'), component('qux', 'BRC')]
}}
selected={null}
/>
)
).toMatchSnapshot();
});

function component(key /*: string */, qualifier /*: string */ = 'TRK') {
it('should render no results', () => {
// eslint-disable-next-line react/display-name
expect(shallowRender({ renderNoResults: () => <div id="no-results" /> })).toMatchSnapshot();
});

function component(key: string, qualifier = 'TRK') {
return { key, name: key, qualifier };
}

function shallowRender(props: Partial<Props> = {}) {
return shallow(
<SearchResults
allowMore={true}
more={{}}
onMoreClick={jest.fn()}
onSelect={jest.fn()}
renderNoResults={() => <div />}
renderResult={() => <div />}
results={{}}
{...props}
/>
);
}

+ 61
- 0
server/sonar-web/src/main/js/app/components/search/__tests__/SearchShowMore-test.tsx View File

@@ -0,0 +1,61 @@
/*
* SonarQube
* Copyright (C) 2009-2018 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import SearchShowMore from '../SearchShowMore';
import { click } from '../../../../helpers/testUtils';

it('should render', () => {
expect(shallowRender()).toMatchSnapshot();
});

it('should trigger showing more', () => {
const onMoreClick = jest.fn();
const wrapper = shallowRender({ onMoreClick });
click(wrapper.find('a'), {
currentTarget: {
blur() {},
dataset: { qualifier: 'TRK' },
preventDefault() {},
stopPropagation() {}
}
});
expect(onMoreClick).toBeCalledWith('TRK');
});

it('should select on mouse over', () => {
const onSelect = jest.fn();
const wrapper = shallowRender({ onSelect });
wrapper.find('a').simulate('mouseenter', { currentTarget: { dataset: { qualifier: 'TRK' } } });
expect(onSelect).toBeCalledWith('qualifier###TRK');
});

function shallowRender(props: Partial<SearchShowMore['props']> = {}) {
return shallow(
<SearchShowMore
allowMore={true}
onMoreClick={jest.fn()}
onSelect={jest.fn()}
qualifier="TRK"
selected={false}
{...props}
/>
);
}

server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.js.snap → server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/Search-test.tsx.snap View File


server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.js.snap → server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResult-test.tsx.snap View File


server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.js.snap → server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchResults-test.tsx.snap View File

@@ -23,7 +23,6 @@ exports[`renders "Show More" link 1`] = `
<SearchShowMore
allowMore={true}
key="more-TRK"
loadingMore={null}
onMoreClick={[MockFunction]}
onSelect={[MockFunction]}
qualifier="TRK"
@@ -109,3 +108,9 @@ exports[`renders different components and dividers between them 1`] = `
</span>
</ul>
`;

exports[`should render no results 1`] = `
<div
id="no-results"
/>
`;

+ 34
- 0
server/sonar-web/src/main/js/app/components/search/__tests__/__snapshots__/SearchShowMore-test.tsx.snap View File

@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render 1`] = `
<li
className="menu-footer"
key="more-TRK"
>
<DeferredSpinner
className="navbar-search-icon"
loading={false}
timeout={100}
>
<a
className=""
data-qualifier="TRK"
href="#"
onClick={[Function]}
onMouseEnter={[Function]}
>
<div
className="pull-right text-muted-2 menu-footer-note"
dangerouslySetInnerHTML={
Object {
"__html": "search.show_more.hint.<span class=\\"shortcut-button shortcut-button-small\\">Enter</span>",
}
}
/>
<span>
show_more
</span>
</a>
</DeferredSpinner>
</li>
`;

server/sonar-web/src/main/js/app/components/search/utils.js → server/sonar-web/src/main/js/app/components/search/utils.ts View File

@@ -17,32 +17,29 @@
* 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 { sortBy } from 'lodash';

const ORDER = ['DEV', 'VW', 'SVW', 'APP', 'TRK', 'BRC', 'FIL', 'UTS'];

export function sortQualifiers(qualifiers /*: Array<string> */) {
export function sortQualifiers(qualifiers: string[]) {
return sortBy(qualifiers, qualifier => ORDER.indexOf(qualifier));
}

/*::
export type Component = {
isFavorite?: boolean,
isRecentlyBrowsed?: boolean,
key: string,
match?: string,
name: string,
organization?: string,
project?: string,
qualifier: string
};
*/
export interface ComponentResult {
isFavorite?: boolean;
isRecentlyBrowsed?: boolean;
key: string;
match?: string;
name: string;
organization?: string;
project?: string;
qualifier: string;
}

/*::
export type Results = { [qualifier: string]: Array<Component> };
*/
export interface Results {
[qualifier: string]: ComponentResult[];
}

/*::
export type More = { [string]: number };
*/
export interface More {
[qualifier: string]: number;
}

Loading…
Cancel
Save