Commit 7194c6d9 by Dominik Prokop Committed by Torkel Ödegaard

Search: Enable filtering dashboards in search by current folder (#16790)

* Added search-query-parser package

* Migrate search input field to react and enable current folter filtering

* Reveiw changes

* FIx tags

* Fix event handlers  passed to html elements directly

* noImplicitAny fix

* Debounce search method in search controller

* Search: have clear reset query as well
parent 2e326d1c
...@@ -218,6 +218,7 @@ ...@@ -218,6 +218,7 @@
"reselect": "4.0.0", "reselect": "4.0.0",
"rst2html": "github:thoward/rst2html#990cb89", "rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "6.4.0", "rxjs": "6.4.0",
"search-query-parser": "1.5.2",
"slate": "0.33.8", "slate": "0.33.8",
"slate-plain-serializer": "0.5.41", "slate-plain-serializer": "0.5.41",
"slate-prism": "0.5.0", "slate-prism": "0.5.0",
......
...@@ -11,6 +11,7 @@ import { MetricSelect } from './components/Select/MetricSelect'; ...@@ -11,6 +11,7 @@ import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList'; import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui'; import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui';
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
import { SearchField } from './components/search/SearchField';
export function registerAngularDirectives() { export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']); react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
...@@ -20,6 +21,12 @@ export function registerAngularDirectives() { ...@@ -20,6 +21,12 @@ export function registerAngularDirectives() {
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']); react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']); react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []); react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('searchField', SearchField, [
'query',
'autoFocus',
['onChange', { watchDepth: 'reference' }],
['onKeyDown', { watchDepth: 'reference' }],
]);
react2AngularDirective('tagFilter', TagFilter, [ react2AngularDirective('tagFilter', TagFilter, [
'tags', 'tags',
['onChange', { watchDepth: 'reference' }], ['onChange', { watchDepth: 'reference' }],
......
import React, { useContext } from 'react';
import tinycolor from 'tinycolor2';
import { SearchQuery } from './search';
import { css, cx } from 'emotion';
import { ThemeContext, GrafanaTheme, selectThemeVariant } from '@grafana/ui';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface SearchFieldProps extends Omit<React.HTMLAttributes<HTMLInputElement>, 'onChange'> {
query: SearchQuery;
onChange: (query: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
}
const getSearchFieldStyles = (theme: GrafanaTheme) => ({
wrapper: css`
width: 100%;
height: 55px; /* this variable is not part of GrafanaTheme yet*/
display: flex;
background-color: ${selectThemeVariant(
{
light: theme.colors.white,
dark: theme.colors.dark4,
},
theme.type
)};
position: relative;
`,
input: css`
max-width: 653px;
padding: ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.md};
height: 51px;
box-sizing: border-box;
outline: none;
background: ${selectThemeVariant(
{
light: theme.colors.dark1,
dark: theme.colors.black,
},
theme.type
)};
background-color: ${selectThemeVariant(
{
light: tinycolor(theme.colors.white)
.lighten(4)
.toString(),
dark: theme.colors.dark4,
},
theme.type
)};
flex-grow: 10;
`,
spacer: css`
flex-grow: 1;
`,
icon: cx(
css`
font-size: ${theme.typography.size.lg};
padding: ${theme.spacing.md} ${theme.spacing.md} ${theme.spacing.sm} ${theme.spacing.md};
`,
'pointer'
),
});
export const SearchField: React.FunctionComponent<SearchFieldProps> = ({ query, onChange, ...inputProps }) => {
const theme = useContext(ThemeContext);
const styles = getSearchFieldStyles(theme);
return (
<>
{/* search-field-wrapper class name left on purpose until we migrate entire search to React */}
{/* based on it GrafanaCtrl (L256) decides whether or not hide search */}
<div className={`${styles.wrapper} search-field-wrapper`}>
<div className={styles.icon}>
<i className="fa fa-search" />
</div>
<input
type="text"
placeholder="Find dashboards by name"
value={query.query}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.currentTarget.value);
}}
tabIndex={1}
spellCheck={false}
{...inputProps}
className={styles.input}
/>
<div className={styles.spacer} />
</div>
</>
);
};
...@@ -3,19 +3,13 @@ ...@@ -3,19 +3,13 @@
<div class="search-container" ng-if="ctrl.isOpen"> <div class="search-container" ng-if="ctrl.isOpen">
<div class="search-field-wrapper"> <search-field
<div class="search-field-icon pointer" ng-click="ctrl.closeSearch()"><i class="fa fa-search"></i></div> query="ctrl.query"
autoFocus="ctrl.giveSearchFocus"
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1" on-change="ctrl.onQueryChange"
ng-keydown="ctrl.keyDown($event)" on-key-down="ctrl.onKeyDown"
ng-model="ctrl.query.query"
ng-model-options="{ debounce: 500 }"
spellcheck='false'
ng-change="ctrl.search()"
/> />
<div class="search-field-spacer"></div>
</div>
<div class="search-dropdown"> <div class="search-dropdown">
<div class="search-dropdown__col_1"> <div class="search-dropdown__col_1">
...@@ -41,7 +35,7 @@ ...@@ -41,7 +35,7 @@
</a> </a>
</div> </div>
<tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onChange="ctrl.onTagFiltersChanged"> <tag-filter tags="ctrl.query.tags" tagOptions="ctrl.getTags" on-change="ctrl.onTagFiltersChanged">
</tag-filter> </tag-filter>
</div> </div>
......
import _ from 'lodash'; import _, { debounce } from 'lodash';
import coreModule from '../../core_module'; import coreModule from '../../core_module';
import { SearchSrv } from 'app/core/services/search_srv'; import { SearchSrv } from 'app/core/services/search_srv';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { parse, SearchParserOptions, SearchParserResult } from 'search-query-parser';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
export interface SearchQuery {
query: string;
parsedQuery: SearchParserResult;
tags: string[];
starred: boolean;
}
class SearchQueryParser {
config: SearchParserOptions;
constructor(config: SearchParserOptions) {
this.config = config;
}
parse(query: string) {
const parsedQuery = parse(query, this.config);
if (typeof parsedQuery === 'string') {
return {
text: parsedQuery,
} as SearchParserResult;
}
return parsedQuery;
}
}
export class SearchCtrl { export class SearchCtrl {
isOpen: boolean; isOpen: boolean;
query: any; query: SearchQuery;
giveSearchFocus: number; giveSearchFocus: boolean;
selectedIndex: number; selectedIndex: number;
results: any; results: any;
currentSearchId: number; currentSearchId: number;
...@@ -18,21 +46,48 @@ export class SearchCtrl { ...@@ -18,21 +46,48 @@ export class SearchCtrl {
initialFolderFilterTitle: string; initialFolderFilterTitle: string;
isEditor: string; isEditor: string;
hasEditPermissionInFolders: boolean; hasEditPermissionInFolders: boolean;
queryParser: SearchQueryParser;
/** @ngInject */ /** @ngInject */
constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) { constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) {
appEvents.on('show-dash-search', this.openSearch.bind(this), $scope); appEvents.on('show-dash-search', this.openSearch.bind(this), $scope);
appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope); appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
appEvents.on('search-query', debounce(this.search.bind(this), 500), $scope);
this.initialFolderFilterTitle = 'All'; this.initialFolderFilterTitle = 'All';
this.isEditor = contextSrv.isEditor; this.isEditor = contextSrv.isEditor;
this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders; this.hasEditPermissionInFolders = contextSrv.hasEditPermissionInFolders;
this.onQueryChange = this.onQueryChange.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.query = {
query: '',
parsedQuery: { text: '' },
tags: [],
starred: false,
};
this.queryParser = new SearchQueryParser({
keywords: ['folder'],
});
} }
closeSearch() { closeSearch() {
this.isOpen = this.ignoreClose; this.isOpen = this.ignoreClose;
} }
onQueryChange(query: SearchQuery | string) {
if (typeof query === 'string') {
this.query = {
...this.query,
parsedQuery: this.queryParser.parse(query),
query: query,
};
} else {
this.query = query;
}
appEvents.emit('search-query');
}
openSearch(evt, payload) { openSearch(evt, payload) {
if (this.isOpen) { if (this.isOpen) {
this.closeSearch(); this.closeSearch();
...@@ -40,10 +95,15 @@ export class SearchCtrl { ...@@ -40,10 +95,15 @@ export class SearchCtrl {
} }
this.isOpen = true; this.isOpen = true;
this.giveSearchFocus = 0; this.giveSearchFocus = true;
this.selectedIndex = -1; this.selectedIndex = -1;
this.results = []; this.results = [];
this.query = { query: '', tag: [], starred: false }; this.query = {
query: evt ? `${evt.query} ` : '',
parsedQuery: this.queryParser.parse(evt && evt.query),
tags: [],
starred: false,
};
this.currentSearchId = 0; this.currentSearchId = 0;
this.ignoreClose = true; this.ignoreClose = true;
this.isLoading = true; this.isLoading = true;
...@@ -54,12 +114,12 @@ export class SearchCtrl { ...@@ -54,12 +114,12 @@ export class SearchCtrl {
this.$timeout(() => { this.$timeout(() => {
this.ignoreClose = false; this.ignoreClose = false;
this.giveSearchFocus = this.giveSearchFocus + 1; this.giveSearchFocus = true;
this.search(); this.search();
}, 100); }, 100);
} }
keyDown(evt) { onKeyDown(evt: KeyboardEvent) {
if (evt.keyCode === 27) { if (evt.keyCode === 27) {
this.closeSearch(); this.closeSearch();
} }
...@@ -94,7 +154,7 @@ export class SearchCtrl { ...@@ -94,7 +154,7 @@ export class SearchCtrl {
} }
onFilterboxClick() { onFilterboxClick() {
this.giveSearchFocus = 0; this.giveSearchFocus = false;
this.preventClose(); this.preventClose();
} }
...@@ -155,15 +215,29 @@ export class SearchCtrl { ...@@ -155,15 +215,29 @@ export class SearchCtrl {
this.results[selectedItem.folderIndex].selected = true; this.results[selectedItem.folderIndex].selected = true;
} }
searchDashboards() { searchDashboards(folderContext?: string) {
this.currentSearchId = this.currentSearchId + 1; this.currentSearchId = this.currentSearchId + 1;
const localSearchId = this.currentSearchId; const localSearchId = this.currentSearchId;
const folderIds = [];
const { parsedQuery } = this.query;
if (folderContext === 'current') {
folderIds.push(getDashboardSrv().getCurrent().meta.folderId);
}
const query = { const query = {
...this.query, ...this.query,
tag: this.query.tag, query: parsedQuery.text,
tag: this.query.tags,
folderIds,
}; };
return this.searchSrv.search(query).then(results => { return this.searchSrv
.search({
...query,
})
.then(results => {
if (localSearchId < this.currentSearchId) { if (localSearchId < this.currentSearchId) {
return; return;
} }
...@@ -175,20 +249,20 @@ export class SearchCtrl { ...@@ -175,20 +249,20 @@ export class SearchCtrl {
queryHasNoFilters() { queryHasNoFilters() {
const query = this.query; const query = this.query;
return query.query === '' && query.starred === false && query.tag.length === 0; return query.query === '' && query.starred === false && query.tags.length === 0;
} }
filterByTag(tag) { filterByTag(tag) {
if (_.indexOf(this.query.tag, tag) === -1) { if (_.indexOf(this.query.tags, tag) === -1) {
this.query.tag.push(tag); this.query.tags.push(tag);
this.search(); this.search();
} }
} }
removeTag(tag, evt) { removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag); this.query.tags = _.without(this.query.tags, tag);
this.search(); this.search();
this.giveSearchFocus = this.giveSearchFocus + 1; this.giveSearchFocus = true;
evt.stopPropagation(); evt.stopPropagation();
evt.preventDefault(); evt.preventDefault();
} }
...@@ -198,32 +272,36 @@ export class SearchCtrl { ...@@ -198,32 +272,36 @@ export class SearchCtrl {
}; };
onTagFiltersChanged = (tags: string[]) => { onTagFiltersChanged = (tags: string[]) => {
this.query.tag = tags; this.query.tags = tags;
this.search(); this.search();
}; };
clearSearchFilter() { clearSearchFilter() {
this.query.tag = []; this.query.query = '';
this.query.tags = [];
this.search(); this.search();
} }
showStarred() { showStarred() {
this.query.starred = !this.query.starred; this.query.starred = !this.query.starred;
this.giveSearchFocus = this.giveSearchFocus + 1; this.giveSearchFocus = true;
this.search(); this.search();
} }
search() { search() {
this.showImport = false; this.showImport = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.searchDashboards(); this.searchDashboards(this.query.parsedQuery['folder']);
} }
folderExpanding() { folderExpanding() {
this.moveSelection(0); this.moveSelection(0);
} }
private getFlattenedResultForNavigation() { private getFlattenedResultForNavigation(): Array<{
folderIndex: number;
dashboardIndex: number;
}> {
let folderIndex = 0; let folderIndex = 0;
return _.flatMap(this.results, s => { return _.flatMap(this.results, s => {
......
...@@ -61,7 +61,16 @@ export class DashNav extends PureComponent<Props> { ...@@ -61,7 +61,16 @@ export class DashNav extends PureComponent<Props> {
} }
onOpenSearch = () => { onOpenSearch = () => {
appEvents.emit('show-dash-search'); const { dashboard } = this.props;
const haveFolder = dashboard.meta.folderId > 0;
appEvents.emit(
'show-dash-search',
haveFolder
? {
query: 'folder:current',
}
: null
);
}; };
onClose = () => { onClose = () => {
...@@ -142,8 +151,7 @@ export class DashNav extends PureComponent<Props> { ...@@ -142,8 +151,7 @@ export class DashNav extends PureComponent<Props> {
<a className="navbar-page-btn" onClick={this.onOpenSearch}> <a className="navbar-page-btn" onClick={this.onOpenSearch}>
{!this.isInFullscreenOrSettings && <i className="gicon gicon-dashboard" />} {!this.isInFullscreenOrSettings && <i className="gicon gicon-dashboard" />}
{haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>} {haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
{dashboard.title} {dashboard.title} <i className="fa fa-caret-down" />
<i className="fa fa-caret-down" />
</a> </a>
</div> </div>
{this.isSettings && <span className="navbar-settings-title">&nbsp;/ Settings</span>} {this.isSettings && <span className="navbar-settings-title">&nbsp;/ Settings</span>}
......
...@@ -19,34 +19,6 @@ ...@@ -19,34 +19,6 @@
} }
// Search // Search
.search-field-wrapper {
width: 100%;
height: $navbarHeight;
display: flex;
background-color: $navbarBackground;
position: relative;
& > input {
max-width: 653px;
padding: $space-md $space-md $space-sm $space-md;
height: 51px;
box-sizing: border-box;
outline: none;
background: $side-menu-bg;
background-color: $navbarButtonBackground;
flex-grow: 10;
}
}
.search-field-spacer {
flex-grow: 1;
}
.search-field-icon {
font-size: $font-size-lg;
padding: $space-md $space-md $space-sm $space-md;
}
.search-dropdown { .search-dropdown {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
...@@ -15245,6 +15245,11 @@ scss-tokenizer@^0.2.3: ...@@ -15245,6 +15245,11 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8" js-base64 "^2.1.8"
source-map "^0.4.2" source-map "^0.4.2"
search-query-parser@1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/search-query-parser/-/search-query-parser-1.5.2.tgz#f6c8c9ecbde439cbbce75110045944c3cb5fe546"
integrity sha512-PcvjC0eJMmFIYAxUaeaRVLnPHctzsymtMJUSGKv6xJtctGrunihoCItrQ3AcM5eO7q90pNeIVTrLwuqW0LIzyg==
select-hose@^2.0.0: select-hose@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment