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 @@
"reselect": "4.0.0",
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "6.4.0",
"search-query-parser": "1.5.2",
"slate": "0.33.8",
"slate-plain-serializer": "0.5.41",
"slate-prism": "0.5.0",
......
......@@ -11,6 +11,7 @@ import { MetricSelect } from './components/Select/MetricSelect';
import AppNotificationList from './components/AppNotifications/AppNotificationList';
import { ColorPicker, SeriesColorPickerPopoverWithTheme, SecretFormField } from '@grafana/ui';
import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor';
import { SearchField } from './components/search/SearchField';
export function registerAngularDirectives() {
react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
......@@ -20,6 +21,12 @@ export function registerAngularDirectives() {
react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
react2AngularDirective('searchResult', SearchResult, []);
react2AngularDirective('searchField', SearchField, [
'query',
'autoFocus',
['onChange', { watchDepth: 'reference' }],
['onKeyDown', { watchDepth: 'reference' }],
]);
react2AngularDirective('tagFilter', TagFilter, [
'tags',
['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 @@
<div class="search-container" ng-if="ctrl.isOpen">
<div class="search-field-wrapper">
<div class="search-field-icon pointer" ng-click="ctrl.closeSearch()"><i class="fa fa-search"></i></div>
<search-field
query="ctrl.query"
autoFocus="ctrl.giveSearchFocus"
on-change="ctrl.onQueryChange"
on-key-down="ctrl.onKeyDown"
/>
<input type="text" placeholder="Find dashboards by name" give-focus="ctrl.giveSearchFocus" tabindex="1"
ng-keydown="ctrl.keyDown($event)"
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__col_1">
......@@ -41,7 +35,7 @@
</a>
</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>
</div>
......
import _ from 'lodash';
import _, { debounce } from 'lodash';
import coreModule from '../../core_module';
import { SearchSrv } from 'app/core/services/search_srv';
import { contextSrv } from 'app/core/services/context_srv';
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 {
isOpen: boolean;
query: any;
giveSearchFocus: number;
query: SearchQuery;
giveSearchFocus: boolean;
selectedIndex: number;
results: any;
currentSearchId: number;
......@@ -18,21 +46,48 @@ export class SearchCtrl {
initialFolderFilterTitle: string;
isEditor: string;
hasEditPermissionInFolders: boolean;
queryParser: SearchQueryParser;
/** @ngInject */
constructor($scope, private $location, private $timeout, private searchSrv: SearchSrv) {
appEvents.on('show-dash-search', this.openSearch.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.isEditor = contextSrv.isEditor;
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() {
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) {
if (this.isOpen) {
this.closeSearch();
......@@ -40,10 +95,15 @@ export class SearchCtrl {
}
this.isOpen = true;
this.giveSearchFocus = 0;
this.giveSearchFocus = true;
this.selectedIndex = -1;
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.ignoreClose = true;
this.isLoading = true;
......@@ -54,12 +114,12 @@ export class SearchCtrl {
this.$timeout(() => {
this.ignoreClose = false;
this.giveSearchFocus = this.giveSearchFocus + 1;
this.giveSearchFocus = true;
this.search();
}, 100);
}
keyDown(evt) {
onKeyDown(evt: KeyboardEvent) {
if (evt.keyCode === 27) {
this.closeSearch();
}
......@@ -94,7 +154,7 @@ export class SearchCtrl {
}
onFilterboxClick() {
this.giveSearchFocus = 0;
this.giveSearchFocus = false;
this.preventClose();
}
......@@ -155,40 +215,54 @@ export class SearchCtrl {
this.results[selectedItem.folderIndex].selected = true;
}
searchDashboards() {
searchDashboards(folderContext?: string) {
this.currentSearchId = this.currentSearchId + 1;
const localSearchId = this.currentSearchId;
const folderIds = [];
const { parsedQuery } = this.query;
if (folderContext === 'current') {
folderIds.push(getDashboardSrv().getCurrent().meta.folderId);
}
const query = {
...this.query,
tag: this.query.tag,
query: parsedQuery.text,
tag: this.query.tags,
folderIds,
};
return this.searchSrv.search(query).then(results => {
if (localSearchId < this.currentSearchId) {
return;
}
this.results = results || [];
this.isLoading = false;
this.moveSelection(1);
});
return this.searchSrv
.search({
...query,
})
.then(results => {
if (localSearchId < this.currentSearchId) {
return;
}
this.results = results || [];
this.isLoading = false;
this.moveSelection(1);
});
}
queryHasNoFilters() {
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) {
if (_.indexOf(this.query.tag, tag) === -1) {
this.query.tag.push(tag);
if (_.indexOf(this.query.tags, tag) === -1) {
this.query.tags.push(tag);
this.search();
}
}
removeTag(tag, evt) {
this.query.tag = _.without(this.query.tag, tag);
this.query.tags = _.without(this.query.tags, tag);
this.search();
this.giveSearchFocus = this.giveSearchFocus + 1;
this.giveSearchFocus = true;
evt.stopPropagation();
evt.preventDefault();
}
......@@ -198,32 +272,36 @@ export class SearchCtrl {
};
onTagFiltersChanged = (tags: string[]) => {
this.query.tag = tags;
this.query.tags = tags;
this.search();
};
clearSearchFilter() {
this.query.tag = [];
this.query.query = '';
this.query.tags = [];
this.search();
}
showStarred() {
this.query.starred = !this.query.starred;
this.giveSearchFocus = this.giveSearchFocus + 1;
this.giveSearchFocus = true;
this.search();
}
search() {
this.showImport = false;
this.selectedIndex = -1;
this.searchDashboards();
this.searchDashboards(this.query.parsedQuery['folder']);
}
folderExpanding() {
this.moveSelection(0);
}
private getFlattenedResultForNavigation() {
private getFlattenedResultForNavigation(): Array<{
folderIndex: number;
dashboardIndex: number;
}> {
let folderIndex = 0;
return _.flatMap(this.results, s => {
......
......@@ -61,7 +61,16 @@ export class DashNav extends PureComponent<Props> {
}
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 = () => {
......@@ -142,8 +151,7 @@ export class DashNav extends PureComponent<Props> {
<a className="navbar-page-btn" onClick={this.onOpenSearch}>
{!this.isInFullscreenOrSettings && <i className="gicon gicon-dashboard" />}
{haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
{dashboard.title}
<i className="fa fa-caret-down" />
{dashboard.title} <i className="fa fa-caret-down" />
</a>
</div>
{this.isSettings && <span className="navbar-settings-title">&nbsp;/ Settings</span>}
......
......@@ -19,34 +19,6 @@
}
// 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 {
display: flex;
flex-direction: column;
......
......@@ -15245,6 +15245,11 @@ scss-tokenizer@^0.2.3:
js-base64 "^2.1.8"
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:
version "2.0.0"
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