mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
TemplateVariables: Introduces $__searchFilter to Query Variables (#19858)
* WIP: Initial hardcoded version * Feature: Introduces SearchFiltering to Graphite * Feature: Adds searchFiltering to MySql * Tests: Adds tests to Graphite and MySql * Feature: Adds $__searchFilter to TestData * Refactor: Adds searchFilter to Postgres and extracts function * Tests: Adds tests to variable * Refactor: Adds debounce and lodash import optimization * Docs: Adds documentation * Refactor: Removes unused function and fixes typo * Docs: Updates docs * Fixed issue with UI not updating when no was used due to async func and no .apply in the non lazy path
This commit is contained in:
parent
c674fa1d79
commit
cb0e80e7b9
@ -114,6 +114,19 @@ variable with all possible values that exist in the wildcard position.
|
||||
You can also create nested variables that use other variables in their definition. For example
|
||||
`apps.$app.servers.*` uses the variable `$app` in its query definition.
|
||||
|
||||
#### Using `$__searchFilter` to filter results in Query Variable
|
||||
> Available from Grafana 6.5 and above
|
||||
|
||||
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
|
||||
When nothing has been entered by the user the default value for `$__searchFilter` is `*`.
|
||||
|
||||
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `server` while the user types in the dropdown select box.
|
||||
|
||||
Query
|
||||
```bash
|
||||
apps.$app.servers.$__searchFilter
|
||||
```
|
||||
|
||||
### Variable Usage
|
||||
|
||||
You can use a variable in a metric node path or as a parameter to a function.
|
||||
|
@ -276,6 +276,19 @@ the hosts variable only show hosts from the current selected region with a query
|
||||
SELECT hostname FROM my_host WHERE region IN($region)
|
||||
```
|
||||
|
||||
#### Using `$__searchFilter` to filter results in Query Variable
|
||||
> Available from Grafana 6.5 and above
|
||||
|
||||
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
|
||||
When nothing has been entered by the user the default value for `$__searchFilter` is `%`.
|
||||
|
||||
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `hostname` while the user types in the dropdown select box.
|
||||
|
||||
Query
|
||||
```sql
|
||||
SELECT hostname FROM my_host WHERE hostname LIKE $__searchFilter
|
||||
```
|
||||
|
||||
### Using Variables in Queries
|
||||
|
||||
From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically so if it is a string value do not wrap them in quotes in where clauses.
|
||||
|
@ -282,6 +282,19 @@ the hosts variable only show hosts from the current selected region with a query
|
||||
SELECT hostname FROM host WHERE region IN($region)
|
||||
```
|
||||
|
||||
#### Using `$__searchFilter` to filter results in Query Variable
|
||||
> Available from Grafana 6.5 and above
|
||||
|
||||
Using `$__searchFilter` in the query field will filter the query result based on what the user types in the dropdown select box.
|
||||
When nothing has been entered by the user the default value for `$__searchFilter` is `%`.
|
||||
|
||||
The example below shows how to use `$__searchFilter` as part of the query field to enable searching for `hostname` while the user types in the dropdown select box.
|
||||
|
||||
Query
|
||||
```sql
|
||||
SELECT hostname FROM my_host WHERE hostname LIKE $__searchFilter
|
||||
```
|
||||
|
||||
### Using Variables in Queries
|
||||
|
||||
From Grafana 4.3.0 to 4.6.0, template variables are always quoted automatically. If your template variables are strings, do not wrap them in quotes in where clauses.
|
||||
|
@ -1,7 +1,14 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash';
|
||||
import angular, { IScope } from 'angular';
|
||||
import debounce from 'lodash/debounce';
|
||||
import each from 'lodash/each';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
import indexOf from 'lodash/indexOf';
|
||||
import map from 'lodash/map';
|
||||
|
||||
import coreModule from '../core_module';
|
||||
import { GrafanaRootScope } from 'app/routes/GrafanaCtrl';
|
||||
import { containsSearchFilter } from '../../features/templating/variable';
|
||||
|
||||
export class ValueSelectDropdownCtrl {
|
||||
dropdownVisible: any;
|
||||
@ -17,20 +24,25 @@ export class ValueSelectDropdownCtrl {
|
||||
|
||||
hide: any;
|
||||
onUpdated: any;
|
||||
queryHasSearchFilter: boolean;
|
||||
debouncedQueryChanged: Function;
|
||||
|
||||
/** @ngInject */
|
||||
constructor(private $q: any) {}
|
||||
constructor(private $q: any, private $scope: IScope) {
|
||||
this.queryHasSearchFilter = this.variable ? containsSearchFilter(this.variable.query) : false;
|
||||
this.debouncedQueryChanged = debounce(this.queryChanged.bind(this), 200);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.oldVariableText = this.variable.current.text;
|
||||
this.highlightIndex = -1;
|
||||
|
||||
this.options = this.variable.options;
|
||||
this.selectedValues = _.filter(this.options, { selected: true });
|
||||
this.selectedValues = filter(this.options, { selected: true });
|
||||
|
||||
this.tags = _.map(this.variable.tags, value => {
|
||||
this.tags = map(this.variable.tags, value => {
|
||||
let tag = { text: value, selected: false };
|
||||
_.each(this.variable.current.tags, tagObj => {
|
||||
each(this.variable.current.tags, tagObj => {
|
||||
if (tagObj.text === value) {
|
||||
tag = tagObj;
|
||||
}
|
||||
@ -38,8 +50,12 @@ export class ValueSelectDropdownCtrl {
|
||||
return tag;
|
||||
});
|
||||
|
||||
// new behaviour, if this is a query that uses searchfilter it might be a nicer
|
||||
// user experience to show the last typed search query in the input field
|
||||
const query = this.queryHasSearchFilter && this.search && this.search.query ? this.search.query : '';
|
||||
|
||||
this.search = {
|
||||
query: '',
|
||||
query,
|
||||
options: this.options.slice(0, Math.min(this.options.length, 1000)),
|
||||
};
|
||||
|
||||
@ -51,13 +67,13 @@ export class ValueSelectDropdownCtrl {
|
||||
|
||||
if (current.tags && current.tags.length) {
|
||||
// filer out values that are in selected tags
|
||||
const selectedAndNotInTag = _.filter(this.variable.options, option => {
|
||||
const selectedAndNotInTag = filter(this.variable.options, option => {
|
||||
if (!option.selected) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < current.tags.length; i++) {
|
||||
const tag = current.tags[i];
|
||||
if (_.indexOf(tag.values, option.value) !== -1) {
|
||||
if (indexOf(tag.values, option.value) !== -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -65,7 +81,7 @@ export class ValueSelectDropdownCtrl {
|
||||
});
|
||||
|
||||
// convert values to text
|
||||
const currentTexts = _.map(selectedAndNotInTag, 'text');
|
||||
const currentTexts = map(selectedAndNotInTag, 'text');
|
||||
|
||||
// join texts
|
||||
this.linkText = currentTexts.join(' + ');
|
||||
@ -78,14 +94,14 @@ export class ValueSelectDropdownCtrl {
|
||||
}
|
||||
|
||||
clearSelections() {
|
||||
this.selectedValues = _.filter(this.options, { selected: true });
|
||||
this.selectedValues = filter(this.options, { selected: true });
|
||||
|
||||
if (this.selectedValues.length) {
|
||||
_.each(this.options, option => {
|
||||
each(this.options, option => {
|
||||
option.selected = false;
|
||||
});
|
||||
} else {
|
||||
_.each(this.search.options, option => {
|
||||
each(this.search.options, option => {
|
||||
option.selected = true;
|
||||
});
|
||||
}
|
||||
@ -104,8 +120,8 @@ export class ValueSelectDropdownCtrl {
|
||||
return tagValuesPromise.then((values: any) => {
|
||||
tag.values = values;
|
||||
tag.valuesText = values.join(' + ');
|
||||
_.each(this.options, option => {
|
||||
if (_.indexOf(tag.values, option.value) !== -1) {
|
||||
each(this.options, option => {
|
||||
if (indexOf(tag.values, option.value) !== -1) {
|
||||
option.selected = tag.selected;
|
||||
}
|
||||
});
|
||||
@ -128,11 +144,11 @@ export class ValueSelectDropdownCtrl {
|
||||
if (this.search.options.length === 0) {
|
||||
this.commitChanges();
|
||||
} else {
|
||||
this.selectValue(this.search.options[this.highlightIndex], {}, true, false);
|
||||
this.selectValue(this.search.options[this.highlightIndex], {}, true);
|
||||
}
|
||||
}
|
||||
if (evt.keyCode === 32) {
|
||||
this.selectValue(this.search.options[this.highlightIndex], {}, false, false);
|
||||
this.selectValue(this.search.options[this.highlightIndex], {}, false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -140,7 +156,7 @@ export class ValueSelectDropdownCtrl {
|
||||
this.highlightIndex = (this.highlightIndex + direction) % this.search.options.length;
|
||||
}
|
||||
|
||||
selectValue(option: any, event: any, commitChange?: boolean, excludeOthers?: boolean) {
|
||||
selectValue(option: any, event: any, commitChange?: boolean) {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
@ -148,10 +164,9 @@ export class ValueSelectDropdownCtrl {
|
||||
option.selected = this.variable.multi ? !option.selected : true;
|
||||
|
||||
commitChange = commitChange || false;
|
||||
excludeOthers = excludeOthers || false;
|
||||
|
||||
const setAllExceptCurrentTo = (newValue: any) => {
|
||||
_.each(this.options, other => {
|
||||
each(this.options, other => {
|
||||
if (option !== other) {
|
||||
other.selected = newValue;
|
||||
}
|
||||
@ -163,7 +178,9 @@ export class ValueSelectDropdownCtrl {
|
||||
option.selected = true;
|
||||
}
|
||||
|
||||
if (option.text === 'All' || excludeOthers) {
|
||||
if (option.text === 'All') {
|
||||
// always clear search query if all is marked
|
||||
this.search.query = '';
|
||||
setAllExceptCurrentTo(false);
|
||||
commitChange = true;
|
||||
} else if (!this.variable.multi) {
|
||||
@ -178,7 +195,7 @@ export class ValueSelectDropdownCtrl {
|
||||
}
|
||||
|
||||
selectionsChanged(commitChange: boolean) {
|
||||
this.selectedValues = _.filter(this.options, { selected: true });
|
||||
this.selectedValues = filter(this.options, { selected: true });
|
||||
|
||||
if (this.selectedValues.length > 1) {
|
||||
if (this.selectedValues[0].text === 'All') {
|
||||
@ -188,19 +205,19 @@ export class ValueSelectDropdownCtrl {
|
||||
}
|
||||
|
||||
// validate selected tags
|
||||
_.each(this.tags, tag => {
|
||||
each(this.tags, tag => {
|
||||
if (tag.selected) {
|
||||
_.each(tag.values, value => {
|
||||
if (!_.find(this.selectedValues, { value: value })) {
|
||||
each(tag.values, value => {
|
||||
if (!find(this.selectedValues, { value: value })) {
|
||||
tag.selected = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.selectedTags = _.filter(this.tags, { selected: true });
|
||||
this.variable.current.value = _.map(this.selectedValues, 'value');
|
||||
this.variable.current.text = _.map(this.selectedValues, 'text').join(' + ');
|
||||
this.selectedTags = filter(this.tags, { selected: true });
|
||||
this.variable.current.value = map(this.selectedValues, 'value');
|
||||
this.variable.current.text = map(this.selectedValues, 'text').join(' + ');
|
||||
this.variable.current.tags = this.selectedTags;
|
||||
|
||||
if (!this.variable.multi) {
|
||||
@ -224,25 +241,48 @@ export class ValueSelectDropdownCtrl {
|
||||
|
||||
this.dropdownVisible = false;
|
||||
this.updateLinkText();
|
||||
if (this.queryHasSearchFilter) {
|
||||
this.updateLazyLoadedOptions();
|
||||
}
|
||||
|
||||
if (this.variable.current.text !== this.oldVariableText) {
|
||||
this.onUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
queryChanged() {
|
||||
this.highlightIndex = -1;
|
||||
this.search.options = _.filter(this.options, option => {
|
||||
async queryChanged() {
|
||||
if (this.queryHasSearchFilter) {
|
||||
await this.updateLazyLoadedOptions();
|
||||
return;
|
||||
}
|
||||
|
||||
const options = filter(this.options, option => {
|
||||
return option.text.toLowerCase().indexOf(this.search.query.toLowerCase()) !== -1;
|
||||
});
|
||||
|
||||
this.search.options = this.search.options.slice(0, Math.min(this.search.options.length, 1000));
|
||||
this.updateUIBoundOptions(this.$scope, options);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.selectedTags = this.variable.current.tags || [];
|
||||
this.updateLinkText();
|
||||
}
|
||||
|
||||
async updateLazyLoadedOptions() {
|
||||
this.options = await this.lazyLoadOptions(this.search.query);
|
||||
this.updateUIBoundOptions(this.$scope, this.options);
|
||||
}
|
||||
|
||||
async lazyLoadOptions(query: string): Promise<any[]> {
|
||||
await this.variable.updateOptions(query);
|
||||
return this.variable.options;
|
||||
}
|
||||
|
||||
updateUIBoundOptions($scope: IScope, options: any[]) {
|
||||
this.highlightIndex = -1;
|
||||
this.search.options = options.slice(0, Math.min(options.length, 1000));
|
||||
$scope.$apply();
|
||||
}
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
|
@ -2,16 +2,18 @@ import 'app/core/directives/value_select_dropdown';
|
||||
import { ValueSelectDropdownCtrl } from '../directives/value_select_dropdown';
|
||||
// @ts-ignore
|
||||
import q from 'q';
|
||||
import { IScope } from 'angular';
|
||||
|
||||
describe('SelectDropdownCtrl', () => {
|
||||
const tagValuesMap: any = {};
|
||||
const $scope: IScope = {} as IScope;
|
||||
|
||||
ValueSelectDropdownCtrl.prototype.onUpdated = jest.fn();
|
||||
let ctrl: ValueSelectDropdownCtrl;
|
||||
|
||||
describe('Given simple variable', () => {
|
||||
beforeEach(() => {
|
||||
ctrl = new ValueSelectDropdownCtrl(q);
|
||||
ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.variable = {
|
||||
current: { text: 'hej', value: 'hej' },
|
||||
getValuesForTag: (key: string) => {
|
||||
@ -28,7 +30,7 @@ describe('SelectDropdownCtrl', () => {
|
||||
|
||||
describe('Given variable with tags and dropdown is opened', () => {
|
||||
beforeEach(() => {
|
||||
ctrl = new ValueSelectDropdownCtrl(q);
|
||||
ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.variable = {
|
||||
current: { text: 'server-1', value: 'server-1' },
|
||||
options: [
|
||||
@ -131,7 +133,7 @@ describe('SelectDropdownCtrl', () => {
|
||||
|
||||
describe('Given variable with selected tags', () => {
|
||||
beforeEach(() => {
|
||||
ctrl = new ValueSelectDropdownCtrl(q);
|
||||
ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.variable = {
|
||||
current: {
|
||||
text: 'server-1',
|
||||
@ -158,3 +160,113 @@ describe('SelectDropdownCtrl', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryChanged', () => {
|
||||
describe('when called and variable query contains search filter', () => {
|
||||
it('then it should use lazy loading', async () => {
|
||||
const $scope = {} as IScope;
|
||||
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
const options = [
|
||||
{ text: 'server-1', value: 'server-1' },
|
||||
{ text: 'server-2', value: 'server-2' },
|
||||
{ text: 'server-3', value: 'server-3' },
|
||||
];
|
||||
ctrl.lazyLoadOptions = jest.fn().mockResolvedValue(options);
|
||||
ctrl.updateUIBoundOptions = jest.fn();
|
||||
ctrl.search = {
|
||||
query: 'alpha',
|
||||
};
|
||||
ctrl.queryHasSearchFilter = true;
|
||||
|
||||
await ctrl.queryChanged();
|
||||
|
||||
expect(ctrl.lazyLoadOptions).toBeCalledTimes(1);
|
||||
expect(ctrl.lazyLoadOptions).toBeCalledWith('alpha');
|
||||
expect(ctrl.updateUIBoundOptions).toBeCalledTimes(1);
|
||||
expect(ctrl.updateUIBoundOptions).toBeCalledWith($scope, options);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when called and variable query does not contain search filter', () => {
|
||||
it('then it should not use lazy loading', async () => {
|
||||
const $scope = {} as IScope;
|
||||
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.lazyLoadOptions = jest.fn().mockResolvedValue([]);
|
||||
ctrl.updateUIBoundOptions = jest.fn();
|
||||
ctrl.search = {
|
||||
query: 'alpha',
|
||||
};
|
||||
ctrl.queryHasSearchFilter = false;
|
||||
|
||||
await ctrl.queryChanged();
|
||||
|
||||
expect(ctrl.lazyLoadOptions).toBeCalledTimes(0);
|
||||
expect(ctrl.updateUIBoundOptions).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lazyLoadOptions', () => {
|
||||
describe('when called with a query', () => {
|
||||
it('then the variables updateOptions should be called with the query', async () => {
|
||||
const $scope = {} as IScope;
|
||||
const ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.variable = {
|
||||
updateOptions: jest.fn(),
|
||||
options: [
|
||||
{ text: 'server-1', value: 'server-1' },
|
||||
{ text: 'server-2', value: 'server-2' },
|
||||
{ text: 'server-3', value: 'server-3' },
|
||||
],
|
||||
};
|
||||
const query = 'server-1';
|
||||
|
||||
const result = await ctrl.lazyLoadOptions(query);
|
||||
|
||||
expect(ctrl.variable.updateOptions).toBeCalledTimes(1);
|
||||
expect(ctrl.variable.updateOptions).toBeCalledWith(query);
|
||||
expect(result).toEqual(ctrl.variable.options);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUIBoundOptions', () => {
|
||||
describe('when called with options', () => {
|
||||
let options: any[];
|
||||
let ctrl: ValueSelectDropdownCtrl;
|
||||
let $scope: IScope;
|
||||
|
||||
beforeEach(() => {
|
||||
$scope = ({
|
||||
$apply: jest.fn(),
|
||||
} as any) as IScope;
|
||||
options = [];
|
||||
for (let index = 0; index < 1001; index++) {
|
||||
options.push({ text: `server-${index}`, value: `server-${index}` });
|
||||
}
|
||||
ctrl = new ValueSelectDropdownCtrl(q, $scope);
|
||||
ctrl.highlightIndex = 0;
|
||||
ctrl.options = [];
|
||||
ctrl.search = {
|
||||
options: [],
|
||||
};
|
||||
ctrl.updateUIBoundOptions($scope, options);
|
||||
});
|
||||
|
||||
it('then highlightIndex should be reset', () => {
|
||||
expect(ctrl.highlightIndex).toEqual(-1);
|
||||
});
|
||||
|
||||
it('then search.options should be same as options but capped to 1000', () => {
|
||||
expect(ctrl.search.options.length).toEqual(1000);
|
||||
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
expect(ctrl.search.options[index]).toEqual(options[index]);
|
||||
}
|
||||
});
|
||||
|
||||
it('then scope apply should be called', () => {
|
||||
expect($scope.$apply).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import { Variable, containsVariable, assignModelProperties, variableTypes } from './variable';
|
||||
import { assignModelProperties, containsVariable, Variable, variableTypes } from './variable';
|
||||
import { stringToJsRegex } from '@grafana/data';
|
||||
import DatasourceSrv from '../plugins/datasource_srv';
|
||||
import { TemplateSrv } from './template_srv';
|
||||
@ -62,6 +62,7 @@ export class QueryVariable implements Variable {
|
||||
) {
|
||||
// copy model properties to this instance
|
||||
assignModelProperties(this, model, this.defaults);
|
||||
this.updateOptionsFromMetricFindQuery.bind(this);
|
||||
}
|
||||
|
||||
getSaveModel() {
|
||||
@ -91,10 +92,10 @@ export class QueryVariable implements Variable {
|
||||
return this.current.value;
|
||||
}
|
||||
|
||||
updateOptions() {
|
||||
updateOptions(searchFilter?: string) {
|
||||
return this.datasourceSrv
|
||||
.get(this.datasource)
|
||||
.then(this.updateOptionsFromMetricFindQuery.bind(this))
|
||||
.then(ds => this.updateOptionsFromMetricFindQuery(ds, searchFilter))
|
||||
.then(this.updateTags.bind(this))
|
||||
.then(this.variableSrv.validateVariableSelectionState.bind(this.variableSrv, this));
|
||||
}
|
||||
@ -126,8 +127,8 @@ export class QueryVariable implements Variable {
|
||||
});
|
||||
}
|
||||
|
||||
updateOptionsFromMetricFindQuery(datasource: any) {
|
||||
return this.metricFindQuery(datasource, this.query).then((results: any) => {
|
||||
updateOptionsFromMetricFindQuery(datasource: any, searchFilter?: string) {
|
||||
return this.metricFindQuery(datasource, this.query, searchFilter).then((results: any) => {
|
||||
this.options = this.metricNamesToVariableValues(results);
|
||||
if (this.includeAll) {
|
||||
this.addAllOption();
|
||||
@ -139,8 +140,8 @@ export class QueryVariable implements Variable {
|
||||
});
|
||||
}
|
||||
|
||||
metricFindQuery(datasource: any, query: string) {
|
||||
const options: any = { range: undefined, variable: this };
|
||||
metricFindQuery(datasource: any, query: string, searchFilter?: string) {
|
||||
const options: any = { range: undefined, variable: this, searchFilter };
|
||||
|
||||
if (this.refresh === 2) {
|
||||
options.range = this.timeSrv.timeRange();
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { containsVariable, assignModelProperties } from '../variable';
|
||||
import {
|
||||
assignModelProperties,
|
||||
containsSearchFilter,
|
||||
containsVariable,
|
||||
interpolateSearchFilter,
|
||||
SEARCH_FILTER_VARIABLE,
|
||||
} from '../variable';
|
||||
|
||||
describe('containsVariable', () => {
|
||||
describe('when checking if a string contains a variable', () => {
|
||||
@ -68,3 +74,104 @@ describe('assignModelProperties', () => {
|
||||
expect(target.propC).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsSearchFilter', () => {
|
||||
describe('when called without query', () => {
|
||||
it('then it should return false', () => {
|
||||
const result = containsSearchFilter(null);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when called with a query without ${SEARCH_FILTER_VARIABLE}`, () => {
|
||||
it('then it should return false', () => {
|
||||
const result = containsSearchFilter('$app.*');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when called with a query with ${SEARCH_FILTER_VARIABLE}`, () => {
|
||||
it('then it should return false', () => {
|
||||
const result = containsSearchFilter(`$app.${SEARCH_FILTER_VARIABLE}`);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateSearchFilter', () => {
|
||||
describe('when called with a query without ${SEARCH_FILTER_VARIABLE}', () => {
|
||||
it('then it should return query', () => {
|
||||
const query = '$app.*';
|
||||
const options = { searchFilter: 'filter' };
|
||||
const wildcardChar = '*';
|
||||
const quoteLiteral = false;
|
||||
|
||||
const result = interpolateSearchFilter({
|
||||
query,
|
||||
options,
|
||||
wildcardChar,
|
||||
quoteLiteral,
|
||||
});
|
||||
|
||||
expect(result).toEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when called with a query with ${SEARCH_FILTER_VARIABLE}`, () => {
|
||||
const query = `$app.${SEARCH_FILTER_VARIABLE}`;
|
||||
|
||||
describe('and no searchFilter is given', () => {
|
||||
it(`then ${SEARCH_FILTER_VARIABLE} should be replaced by wildchar character`, () => {
|
||||
const options = {};
|
||||
const wildcardChar = '*';
|
||||
const quoteLiteral = false;
|
||||
|
||||
const result = interpolateSearchFilter({
|
||||
query,
|
||||
options,
|
||||
wildcardChar,
|
||||
quoteLiteral,
|
||||
});
|
||||
|
||||
expect(result).toEqual(`$app.*`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and searchFilter is given', () => {
|
||||
const options = { searchFilter: 'filter' };
|
||||
|
||||
it(`then ${SEARCH_FILTER_VARIABLE} should be replaced with searchfilter and wildchar character`, () => {
|
||||
const wildcardChar = '*';
|
||||
const quoteLiteral = false;
|
||||
|
||||
const result = interpolateSearchFilter({
|
||||
query,
|
||||
options,
|
||||
wildcardChar,
|
||||
quoteLiteral,
|
||||
});
|
||||
|
||||
expect(result).toEqual(`$app.filter*`);
|
||||
});
|
||||
|
||||
describe(`and quoteLiteral is used`, () => {
|
||||
it(`then the literal should be quoted`, () => {
|
||||
const wildcardChar = '*';
|
||||
const quoteLiteral = true;
|
||||
|
||||
const result = interpolateSearchFilter({
|
||||
query,
|
||||
options,
|
||||
wildcardChar,
|
||||
quoteLiteral,
|
||||
});
|
||||
|
||||
expect(result).toEqual(`$app.'filter*'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -15,9 +15,36 @@ export const variableRegexExec = (variableString: string) => {
|
||||
return variableRegex.exec(variableString);
|
||||
};
|
||||
|
||||
export const SEARCH_FILTER_VARIABLE = '$__searchFilter';
|
||||
export const containsSearchFilter = (query: string): boolean =>
|
||||
query ? query.indexOf(SEARCH_FILTER_VARIABLE) !== -1 : false;
|
||||
|
||||
export interface InterpolateSearchFilterOptions {
|
||||
query: string;
|
||||
options: any;
|
||||
wildcardChar: string;
|
||||
quoteLiteral: boolean;
|
||||
}
|
||||
|
||||
export const interpolateSearchFilter = (args: InterpolateSearchFilterOptions): string => {
|
||||
const { query, wildcardChar, quoteLiteral } = args;
|
||||
let { options } = args;
|
||||
|
||||
if (!containsSearchFilter(query)) {
|
||||
return query;
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
|
||||
const filter = options.searchFilter ? `${options.searchFilter}${wildcardChar}` : `${wildcardChar}`;
|
||||
const replaceValue = quoteLiteral ? `'${filter}'` : filter;
|
||||
|
||||
return query.replace(SEARCH_FILTER_VARIABLE, replaceValue);
|
||||
};
|
||||
|
||||
export interface Variable {
|
||||
setValue(option: any): any;
|
||||
updateOptions(): any;
|
||||
updateOptions(searchFilter?: string): any;
|
||||
dependsOn(variable: any): any;
|
||||
setValueFromUrl(urlValue: any): any;
|
||||
getValueForUrl(): any;
|
||||
|
@ -10,7 +10,7 @@
|
||||
<i class="fa fa-caret-down" style="font-size:12px"></i>
|
||||
</a>
|
||||
|
||||
<input type="text" class="gf-form-input" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.queryChanged()" ></input>
|
||||
<input type="text" class="gf-form-input" style="display: none" ng-keydown="vm.keyDown($event)" ng-model="vm.search.query" ng-change="vm.debouncedQueryChanged()" ></input>
|
||||
|
||||
<div class="variable-value-dropdown" ng-if="vm.dropdownVisible" ng-class="{'multi': vm.variable.multi, 'single': !vm.variable.multi}">
|
||||
<div class="variable-options-wrapper">
|
||||
|
@ -5,9 +5,9 @@ import gfunc from './gfunc';
|
||||
import { IQService } from 'angular';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
//Types
|
||||
import { GraphiteQuery } from './types';
|
||||
import { interpolateSearchFilter } from '../../../features/templating/variable';
|
||||
|
||||
export class GraphiteDatasource {
|
||||
basicAuth: string;
|
||||
@ -251,7 +251,12 @@ export class GraphiteDatasource {
|
||||
|
||||
metricFindQuery(query: string, optionalOptions: any) {
|
||||
const options: any = optionalOptions || {};
|
||||
const interpolatedQuery = this.templateSrv.replace(query);
|
||||
const interpolatedQuery = interpolateSearchFilter({
|
||||
query: this.templateSrv.replace(query),
|
||||
options: optionalOptions,
|
||||
wildcardChar: '*',
|
||||
quoteLiteral: false,
|
||||
});
|
||||
|
||||
// special handling for tag_values(<tag>[,<expression>]*), this is used for template variables
|
||||
let matches = interpolatedQuery.match(/^tag_values\(([^,]+)((, *[^,]+)*)\)$/);
|
||||
|
@ -356,6 +356,28 @@ describe('graphiteDatasource', () => {
|
||||
expect(requestOptions.data).toMatch(`query=bar`);
|
||||
expect(requestOptions).toHaveProperty('params');
|
||||
});
|
||||
|
||||
it('should interpolate $__searchFilter with searchFilter', () => {
|
||||
ctx.ds.metricFindQuery('app.$__searchFilter', { searchFilter: 'backend' }).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
|
||||
expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find');
|
||||
expect(requestOptions.params).toEqual({});
|
||||
expect(requestOptions.data).toEqual('query=app.backend*');
|
||||
expect(results).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should interpolate $__searchFilter with default when searchFilter is missing', () => {
|
||||
ctx.ds.metricFindQuery('app.$__searchFilter', {}).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
|
||||
expect(requestOptions.url).toBe('/api/datasources/proxy/1/metrics/find');
|
||||
expect(requestOptions.params).toEqual({});
|
||||
expect(requestOptions.data).toEqual('query=app.*');
|
||||
expect(results).not.toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -7,6 +7,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
//Types
|
||||
import { MysqlQueryForInterpolation } from './types';
|
||||
import { interpolateSearchFilter } from '../../../features/templating/variable';
|
||||
|
||||
export class MysqlDatasource {
|
||||
id: any;
|
||||
@ -130,10 +131,17 @@ export class MysqlDatasource {
|
||||
refId = optionalOptions.variable.name;
|
||||
}
|
||||
|
||||
const rawSql = interpolateSearchFilter({
|
||||
query: this.templateSrv.replace(query, {}, this.interpolateVariable),
|
||||
options: optionalOptions,
|
||||
wildcardChar: '%',
|
||||
quoteLiteral: true,
|
||||
});
|
||||
|
||||
const interpolatedQuery = {
|
||||
refId: refId,
|
||||
datasourceId: this.id,
|
||||
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
|
||||
rawSql,
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { MysqlDatasource } from '../datasource';
|
||||
import { CustomVariable } from 'app/features/templating/custom_variable';
|
||||
import { toUtc, dateTime } from '@grafana/data';
|
||||
import { dateTime, toUtc } from '@grafana/data';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
@ -121,6 +121,82 @@ describe('MySQLDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => {
|
||||
let results: any;
|
||||
let calledWith: any = {};
|
||||
const query = 'select title from atable where title LIKE $__searchFilter';
|
||||
const response = {
|
||||
results: {
|
||||
tempvar: {
|
||||
meta: {
|
||||
rowCount: 3,
|
||||
},
|
||||
refId: 'tempvar',
|
||||
tables: [
|
||||
{
|
||||
columns: [{ text: 'title' }, { text: 'text' }],
|
||||
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = jest.fn(options => {
|
||||
calledWith = options;
|
||||
return Promise.resolve({ data: response, status: 200 });
|
||||
});
|
||||
ctx.ds.metricFindQuery(query, { searchFilter: 'aTit' }).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return list of all column values', () => {
|
||||
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
|
||||
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE 'aTit%'");
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => {
|
||||
let results: any;
|
||||
let calledWith: any = {};
|
||||
const query = 'select title from atable where title LIKE $__searchFilter';
|
||||
const response = {
|
||||
results: {
|
||||
tempvar: {
|
||||
meta: {
|
||||
rowCount: 3,
|
||||
},
|
||||
refId: 'tempvar',
|
||||
tables: [
|
||||
{
|
||||
columns: [{ text: 'title' }, { text: 'text' }],
|
||||
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = jest.fn(options => {
|
||||
calledWith = options;
|
||||
return Promise.resolve({ data: response, status: 200 });
|
||||
});
|
||||
ctx.ds.metricFindQuery(query, {}).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return list of all column values', () => {
|
||||
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
|
||||
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'");
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with key, value columns', () => {
|
||||
let results: any;
|
||||
const query = 'select * from atable';
|
||||
|
@ -7,6 +7,7 @@ import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
//Types
|
||||
import { PostgresQueryForInterpolation } from './types';
|
||||
import { interpolateSearchFilter } from '../../../features/templating/variable';
|
||||
|
||||
export class PostgresDatasource {
|
||||
id: any;
|
||||
@ -132,10 +133,17 @@ export class PostgresDatasource {
|
||||
refId = optionalOptions.variable.name;
|
||||
}
|
||||
|
||||
const rawSql = interpolateSearchFilter({
|
||||
query: this.templateSrv.replace(query, {}, this.interpolateVariable),
|
||||
options: optionalOptions,
|
||||
wildcardChar: '%',
|
||||
quoteLiteral: true,
|
||||
});
|
||||
|
||||
const interpolatedQuery = {
|
||||
refId: refId,
|
||||
datasourceId: this.id,
|
||||
rawSql: this.templateSrv.replace(query, {}, this.interpolateVariable),
|
||||
rawSql,
|
||||
format: 'table',
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { PostgresDatasource } from '../datasource';
|
||||
import { CustomVariable } from 'app/features/templating/custom_variable';
|
||||
import { toUtc, dateTime } from '@grafana/data';
|
||||
import { dateTime, toUtc } from '@grafana/data';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { IQService } from 'angular';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
@ -128,6 +128,82 @@ describe('PostgreSQLDatasource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with $__searchFilter and a searchFilter is given', () => {
|
||||
let results: any;
|
||||
let calledWith: any = {};
|
||||
const query = 'select title from atable where title LIKE $__searchFilter';
|
||||
const response = {
|
||||
results: {
|
||||
tempvar: {
|
||||
meta: {
|
||||
rowCount: 3,
|
||||
},
|
||||
refId: 'tempvar',
|
||||
tables: [
|
||||
{
|
||||
columns: [{ text: 'title' }, { text: 'text' }],
|
||||
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = jest.fn(options => {
|
||||
calledWith = options;
|
||||
return Promise.resolve({ data: response, status: 200 });
|
||||
});
|
||||
ctx.ds.metricFindQuery(query, { searchFilter: 'aTit' }).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return list of all column values', () => {
|
||||
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
|
||||
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE 'aTit%'");
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with $__searchFilter but no searchFilter is given', () => {
|
||||
let results: any;
|
||||
let calledWith: any = {};
|
||||
const query = 'select title from atable where title LIKE $__searchFilter';
|
||||
const response = {
|
||||
results: {
|
||||
tempvar: {
|
||||
meta: {
|
||||
rowCount: 3,
|
||||
},
|
||||
refId: 'tempvar',
|
||||
tables: [
|
||||
{
|
||||
columns: [{ text: 'title' }, { text: 'text' }],
|
||||
rows: [['aTitle', 'some text'], ['aTitle2', 'some text2'], ['aTitle3', 'some text3']],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.backendSrv.datasourceRequest = jest.fn(options => {
|
||||
calledWith = options;
|
||||
return Promise.resolve({ data: response, status: 200 });
|
||||
});
|
||||
ctx.ds.metricFindQuery(query, {}).then((data: any) => {
|
||||
results = data;
|
||||
});
|
||||
});
|
||||
|
||||
it('should return list of all column values', () => {
|
||||
expect(ctx.backendSrv.datasourceRequest).toBeCalledTimes(1);
|
||||
expect(calledWith.data.queries[0].rawSql).toBe("select title from atable where title LIKE '%'");
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When performing metricFindQuery with key, value columns', () => {
|
||||
let results: any;
|
||||
const query = 'select * from atable';
|
||||
|
@ -1,18 +1,18 @@
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
DataSourceApi,
|
||||
DataQueryRequest,
|
||||
DataSourceInstanceSettings,
|
||||
DataQueryResponse,
|
||||
DataSourceApi,
|
||||
DataSourceInstanceSettings,
|
||||
MetricFindValue,
|
||||
} from '@grafana/ui';
|
||||
import { TableData, TimeSeries } from '@grafana/data';
|
||||
import { TestDataQuery, Scenario } from './types';
|
||||
import { Scenario, TestDataQuery } from './types';
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
import { queryMetricTree } from './metricTree';
|
||||
import { Observable, from, merge } from 'rxjs';
|
||||
import { from, merge, Observable } from 'rxjs';
|
||||
import { runStream } from './runStreams';
|
||||
import templateSrv from 'app/features/templating/template_srv';
|
||||
import { interpolateSearchFilter } from '../../../features/templating/variable';
|
||||
|
||||
type TestData = TimeSeries | TableData;
|
||||
|
||||
@ -119,10 +119,16 @@ export class TestDataDataSource extends DataSourceApi<TestDataQuery> {
|
||||
return getBackendSrv().get('/api/tsdb/testdata/scenarios');
|
||||
}
|
||||
|
||||
metricFindQuery(query: string) {
|
||||
metricFindQuery(query: string, options: any) {
|
||||
return new Promise<MetricFindValue[]>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
const children = queryMetricTree(templateSrv.replace(query));
|
||||
const interpolatedQuery = interpolateSearchFilter({
|
||||
query: templateSrv.replace(query),
|
||||
options,
|
||||
wildcardChar: '*',
|
||||
quoteLiteral: false,
|
||||
});
|
||||
const children = queryMetricTree(interpolatedQuery);
|
||||
const items = children.map(item => ({ value: item.name, text: item.name }));
|
||||
resolve(items);
|
||||
}, 100);
|
||||
|
@ -16,4 +16,9 @@ describe('MetricTree', () => {
|
||||
const nodes = queryMetricTree('A.{AB,AC}.*').map(i => i.name);
|
||||
expect(nodes).toEqual(['ABA', 'ABB', 'ABC', 'ACA', 'ACB', 'ACC']);
|
||||
});
|
||||
|
||||
it('queryMetric tree supports wildcard matching', () => {
|
||||
const nodes = queryMetricTree('A.AB.AB*').map(i => i.name);
|
||||
expect(nodes).toEqual(['ABA', 'ABB', 'ABC']);
|
||||
});
|
||||
});
|
||||
|
@ -35,6 +35,10 @@ function buildMetricTree(parent: string, depth: number): TreeNode[] {
|
||||
}
|
||||
|
||||
function queryTree(children: TreeNode[], query: string[], queryIndex: number): TreeNode[] {
|
||||
if (queryIndex >= query.length) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (query[queryIndex] === '*') {
|
||||
return children;
|
||||
}
|
||||
@ -50,7 +54,13 @@ function queryTree(children: TreeNode[], query: string[], queryIndex: number): T
|
||||
|
||||
for (const node of children) {
|
||||
for (const nameToMatch of namesToMatch) {
|
||||
if (node.name === nameToMatch) {
|
||||
if (nameToMatch.indexOf('*') !== -1) {
|
||||
const pattern = nameToMatch.replace('*', '');
|
||||
const regex = new RegExp(`^${pattern}.*`, 'gi');
|
||||
if (regex.test(node.name)) {
|
||||
result = result.concat(queryTree([node], query, queryIndex + 1));
|
||||
}
|
||||
} else if (node.name === nameToMatch) {
|
||||
result = result.concat(queryTree(node.children, query, queryIndex + 1));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user