@@ -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); |
@@ -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)]) | |||
); | |||
}); |
@@ -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, |
@@ -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> {} |
@@ -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); |
@@ -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; | |||
} |
@@ -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() | |||
); | |||
} | |||
} |
@@ -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() | |||
); | |||
} |
@@ -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() { |
@@ -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); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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" | |||
/> | |||
`; |
@@ -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> | |||
`; |
@@ -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; | |||
} |