Variables: Removes experimental Tags feature (#33361)

* Variables: Removes experimental Tags feature

* Refactor: adds dashboard migration

* Tests: fixes snapshots

* Docs: removes docs for experimental feature

* Refactor: dummy change

* Docs: removes reference
This commit is contained in:
Hugo Häggmark
2021-04-27 05:57:25 +02:00
committed by GitHub
parent 408bc06bab
commit f73be970d3
27 changed files with 130 additions and 1119 deletions

View File

@ -39,6 +39,5 @@ Query expressions are different for each data source. For more information, refe
1. (optional) In the **Regex** field, type a regex expression to filter or capture specific parts of the names returned by your data source query. To see examples, refer to [Filter variables with regex]({{< relref "../filter-variables-with-regex.md" >}}).
1. In the **Sort** list, select the sort order for values to be displayed in the dropdown list. The default option, **Disabled**, means that the order of options returned by your data source query will be used.
1. (optional) Enter [Selection Options]({{< relref "../variable-selection-options.md" >}}).
1. (optional) Enter [Value groups/tags]({{< relref "../variable-value-tags.md" >}}).
1. In **Preview of values**, Grafana displays a list of the current variable values. Review them to ensure they match what you expect.
1. Click **Add** to add the variable to the dashboard.

View File

@ -1,34 +0,0 @@
+++
title = "Variable value group tags"
weight = 500
+++
# Configure variable value group tags
> **Note:** This is an experimental feature that will be deprecated in Grafana v8.
Value groups/tags are a feature you can use to organize variable options. If you have many options in the dropdown for a multi-value variable, then you can use this feature to group the values into selectable tags.
{{< docs-imagebox img="/img/docs/v50/variable_dropdown_tags.png" max-width="300px" >}}
This feature is off by default. Click **Enabled** to turn the feature on.
To see an example, check out [Templating value groups](https://play.grafana.org/d/000000024/templating-value-groups?orgId=1).
## Tags query
Enter a data source query that should return a list of tags. The tags query returns a list of tags that each represents a group, and the tag values query returns a list of group members.
For example, the tags query could be a list of regions (Europe, Asia, Americas), and then if the user selects the Europe tag, then the tag values query would return a list of countries -- Sweden, Germany, France, and so on.
If you have a variable with a lot of values (say all the countries in the world), then this allows you to easily select a group of them. If the user selects the tag Europe, all the countries in Europe would be selected.
In this [example dashboard](https://play.grafana.org/d/ZUPhFVGGk/graphite-with-experimental-tags?orgId=1), the server variable has tags enabled.
## Tag values query
Enter a data source query that should return a list of values for a specified tag key. Use `$tag` in the query to refer to the currently selected tag.
The `$tag` variable will have the value of the tag that the user chooses.
For example, if you have a Graphite query for tags, `regions.*`, that returns a list of regions. The values query could be `regions.$tag.*`, which if the user chooses Europe would be interpolated to `regions.Europe.*`.

View File

@ -65,8 +65,6 @@ describe('Variables - Add variable', () => {
e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch().should('not.be.checked');
e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch().should('not.be.checked');
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.valueGroupsTagsEnabledSwitch().should('not.be.checked');
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('not.exist');
e2e.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput().should('not.exist');
});

View File

@ -88,7 +88,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -220,7 +220,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -322,7 +322,7 @@ exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -446,7 +446,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -578,7 +578,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -680,7 +680,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -786,7 +786,7 @@ exports[`DashboardPage When dashboard has editview url state should render setti
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],

View File

@ -228,7 +228,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -469,7 +469,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -710,7 +710,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
@ -951,7 +951,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 27,
"schemaVersion": 28,
"snapshot": undefined,
"style": "dark",
"tags": Array [],

View File

@ -133,7 +133,7 @@ describe('DashboardModel', () => {
});
it('dashboard schema version should be set to latest', () => {
expect(model.schemaVersion).toBe(27);
expect(model.schemaVersion).toBe(28);
});
it('graph thresholds should be migrated', () => {
@ -628,7 +628,7 @@ describe('DashboardModel', () => {
});
});
describe('when migrating variables with old tags format', () => {
describe('when migrating variables with tags', () => {
let model: DashboardModel;
beforeEach(() => {
@ -638,6 +638,9 @@ describe('DashboardModel', () => {
{
type: 'query',
tags: ['Africa', 'America', 'Asia', 'Europe'],
tagsQuery: 'select datacenter from x',
tagValuesQuery: 'select value from x where datacenter = xyz',
useTags: true,
},
{
type: 'query',
@ -660,6 +663,9 @@ describe('DashboardModel', () => {
value: ['server-us-east', 'server-us-central', 'server-us-west', 'server-eu-east', 'server-eu-west'],
},
tags: ['Africa', 'America', 'Asia', 'Europe'],
tagsQuery: 'select datacenter from x',
tagValuesQuery: 'select value from x where datacenter = xyz',
useTags: true,
},
{
type: 'query',
@ -669,6 +675,9 @@ describe('DashboardModel', () => {
{ text: 'Asia', selected: false },
{ text: 'Europe', selected: false },
],
tagsQuery: 'select datacenter from x',
tagValuesQuery: 'select value from x where datacenter = xyz',
useTags: true,
},
],
},
@ -679,41 +688,28 @@ describe('DashboardModel', () => {
expect(model.templating.list.length).toBe(3);
});
it('should be migrated with defaults if being out of sync', () => {
expect(model.templating.list[0].tags).toEqual([
{ text: 'Africa', selected: false },
{ text: 'America', selected: false },
{ text: 'Asia', selected: false },
{ text: 'Europe', selected: false },
]);
it('should have no tags', () => {
expect(model.templating.list[0].tags).toBeUndefined();
expect(model.templating.list[1].tags).toBeUndefined();
expect(model.templating.list[2].tags).toBeUndefined();
});
it('should be migrated with current values if being out of sync', () => {
expect(model.templating.list[1].tags).toEqual([
{ text: 'Africa', selected: false },
{
selected: true,
text: 'America',
values: ['server-us-east', 'server-us-central', 'server-us-west'],
valuesText: 'server-us-east + server-us-central + server-us-west',
},
{ text: 'Asia', selected: false },
{
selected: true,
text: 'Europe',
values: ['server-eu-east', 'server-eu-west'],
valuesText: 'server-eu-east + server-eu-west',
},
]);
it('should have no tagsQuery property', () => {
expect(model.templating.list[0].tagsQuery).toBeUndefined();
expect(model.templating.list[1].tagsQuery).toBeUndefined();
expect(model.templating.list[2].tagsQuery).toBeUndefined();
});
it('should not be migrated if being in sync', () => {
expect(model.templating.list[2].tags).toEqual([
{ text: 'Africa', selected: false },
{ text: 'America', selected: true },
{ text: 'Asia', selected: false },
{ text: 'Europe', selected: false },
]);
it('should have no tagValuesQuery property', () => {
expect(model.templating.list[0].tagValuesQuery).toBeUndefined();
expect(model.templating.list[1].tagValuesQuery).toBeUndefined();
expect(model.templating.list[2].tagValuesQuery).toBeUndefined();
});
it('should have no useTags property', () => {
expect(model.templating.list[0].useTags).toBeUndefined();
expect(model.templating.list[1].useTags).toBeUndefined();
expect(model.templating.list[2].useTags).toBeUndefined();
});
});

View File

@ -1,18 +1,5 @@
// Libraries
import {
defaults,
each,
find,
findIndex,
flattenDeep,
isArray,
isBoolean,
isNumber,
isString,
map,
max,
some,
} from 'lodash';
import { each, find, findIndex, flattenDeep, isArray, isBoolean, isNumber, isString, map, max, some } from 'lodash';
// Utils
import getFactors from 'app/core/utils/factors';
import kbn from 'app/core/utils/kbn';
@ -29,9 +16,9 @@ import {
GRID_COLUMN_COUNT,
MIN_PANEL_HEIGHT,
} from 'app/core/constants';
import { isConstant, isMulti, isQuery } from 'app/features/variables/guard';
import { isConstant, isMulti } from 'app/features/variables/guard';
import { alignCurrentWithMulti } from 'app/features/variables/shared/multiOptions';
import { VariableHide, VariableTag } from '../../variables/types';
import { VariableHide } from '../../variables/types';
export class DashboardMigrator {
dashboard: DashboardModel;
@ -44,7 +31,7 @@ export class DashboardMigrator {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = [];
this.dashboard.schemaVersion = 27;
this.dashboard.schemaVersion = 28;
if (oldVersion === this.dashboard.schemaVersion) {
return;
@ -537,43 +524,7 @@ export class DashboardMigrator {
}
if (oldVersion < 25) {
for (const variable of this.dashboard.templating.list) {
if (!isQuery(variable)) {
continue;
}
const { tags, current } = variable;
if (!Array.isArray(tags)) {
variable.tags = [];
continue;
}
const currentTags = current?.tags ?? [];
const currents = currentTags.reduce((all, tag) => {
if (tag && tag.hasOwnProperty('text') && typeof tag['text'] === 'string') {
all[tag.text] = tag;
}
return all;
}, {} as Record<string, VariableTag>);
const newTags: VariableTag[] = [];
for (const tag of tags) {
if (typeof tag === 'object') {
// new format let's assume it's correct
newTags.push(tag);
continue;
}
if (typeof tag !== 'string') {
// something that we do not support
continue;
}
newTags.push(defaults(currents[tag], { text: tag, selected: false }));
}
variable.tags = newTags;
}
// tags are removed in version 28
}
if (oldVersion < 26) {
@ -603,6 +554,26 @@ export class DashboardMigrator {
}
}
if (oldVersion < 28) {
for (const variable of this.dashboard.templating.list) {
if (variable.tags) {
delete variable.tags;
}
if (variable.tagsQuery) {
delete variable.tagsQuery;
}
if (variable.tagValuesQuery) {
delete variable.tagValuesQuery;
}
if (variable.useTags) {
delete variable.useTags;
}
}
}
if (panelUpgrades.length === 0) {
return;
}

View File

@ -5,17 +5,11 @@ import { LoadingState } from '@grafana/data';
import { StoreState } from 'app/types';
import { VariableInput } from '../shared/VariableInput';
import {
commitChangesToVariable,
filterOrSearchOptions,
navigateOptions,
openOptions,
toggleAndFetchTag,
} from './actions';
import { commitChangesToVariable, filterOrSearchOptions, navigateOptions, openOptions } from './actions';
import { OptionsPickerState, toggleAllOptions, toggleOption } from './reducer';
import { VariableOption, VariableTag, VariableWithMultiSupport, VariableWithOptions } from '../../types';
import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types';
import { VariableOptions } from '../shared/VariableOptions';
import { isMulti, isQuery } from '../../guard';
import { isMulti } from '../../guard';
import { VariablePickerProps } from '../types';
import { formatVariableLabel } from '../../shared/formatVariable';
import { toVariableIdentifier } from '../../state/types';
@ -31,7 +25,6 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
filterOrSearchOptions,
toggleAllOptions,
toggleOption,
toggleAndFetchTag,
navigateOptions,
};
@ -80,18 +73,9 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
renderLink(variable: VariableWithOptions) {
const linkText = formatVariableLabel(variable);
const tags = getSelectedTags(variable);
const loading = variable.state === LoadingState.Loading;
return (
<VariableLink
text={linkText}
tags={tags}
onClick={this.onShowOptions}
loading={loading}
onCancel={this.onCancel}
/>
);
return <VariableLink text={linkText} onClick={this.onShowOptions} loading={loading} onCancel={this.onCancel} />;
}
onCancel = () => {
@ -110,10 +94,8 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
values={picker.options}
onToggle={this.onToggleOption}
onToggleAll={this.props.toggleAllOptions}
onToggleTag={this.props.toggleAndFetchTag}
highlightIndex={picker.highlightIndex}
multi={picker.multi}
tags={picker.tags}
selectedValues={picker.selectedValues}
/>
</ClickOutsideWrapper>
@ -126,10 +108,3 @@ export const optionPickerFactory = <Model extends VariableWithOptions | Variable
return OptionsPicker;
};
const getSelectedTags = (variable: VariableWithOptions): VariableTag[] => {
if (!isQuery(variable) || !Array.isArray(variable.tags)) {
return [];
}
return variable.tags.filter((t) => t.selected);
};

View File

@ -7,7 +7,6 @@ import {
moveOptionsHighlight,
showOptions,
toggleOption,
toggleTag,
updateOptionsAndFilter,
updateSearchQuery,
} from './reducer';
@ -16,7 +15,6 @@ import {
filterOrSearchOptions,
navigateOptions,
openOptions,
toggleAndFetchTag,
toggleOptionByHighlight,
} from './actions';
import { NavigationKey } from '../types';
@ -69,7 +67,6 @@ describe('options picker actions', () => {
...createOption(['A']),
selected: true,
value: ['A'],
tags: [] as any[],
};
tester.thenDispatchedActionsShouldEqual(
@ -191,7 +188,6 @@ describe('options picker actions', () => {
...createOption(['B']),
selected: true,
value: ['B'],
tags: [] as any[],
};
tester.thenDispatchedActionsShouldEqual(
@ -331,7 +327,6 @@ describe('options picker actions', () => {
...createOption(['A']),
selected: true,
value: ['A'] as any[],
tags: [] as any[],
};
tester.thenDispatchedActionsShouldEqual(
@ -360,7 +355,6 @@ describe('options picker actions', () => {
...createOption([]),
selected: true,
value: [],
tags: [] as any[],
};
tester.thenDispatchedActionsShouldEqual(
@ -392,7 +386,6 @@ describe('options picker actions', () => {
...createOption([]),
selected: true,
value: [],
tags: [] as any[],
};
tester.thenDispatchedActionsShouldEqual(
@ -454,53 +447,6 @@ describe('options picker actions', () => {
);
});
});
describe('when toggleAndFetchTag is dispatched with values', () => {
it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('C')];
const tag = createTag('tag', []);
const variable = createMultiVariable({
options,
current: createOption(['A'], ['A'], true),
includeAll: false,
tags: [tag],
});
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(showOptions(variable))
.whenAsyncActionIsDispatched(toggleAndFetchTag(tag), true);
tester.thenDispatchedActionsShouldEqual(toggleTag(tag));
});
});
describe('when toggleAndFetchTag is dispatched without values', () => {
it('then correct actions are dispatched', async () => {
const options = [createOption('A'), createOption('B'), createOption('C')];
const tag = createTag('tag');
const values = [createMetric('b')];
const variable = createMultiVariable({
options,
current: createOption(['A'], ['A'], true),
includeAll: false,
tags: [tag],
});
datasource.metricFindQuery.mockReset();
// @ts-ignore strict null error TS2345: Argument of type '() => Promise<{ value: string; text: string; }[]>' is not assignable to parameter of type '() => Promise<never[]>'
datasource.metricFindQuery.mockImplementation(() => Promise.resolve(values));
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenActionIsDispatched(showOptions(variable))
.whenAsyncActionIsDispatched(toggleAndFetchTag(tag), true);
tester.thenDispatchedActionsShouldEqual(toggleTag({ ...tag, values: ['b'] }));
});
});
});
function createMultiVariable(extend?: Partial<QueryVariableModel>): QueryVariableModel {
@ -516,10 +462,6 @@ function createMultiVariable(extend?: Partial<QueryVariableModel>): QueryVariabl
datasource: 'datasource',
definition: '',
sort: VariableSort.alphabeticalAsc,
tags: [],
tagsQuery: 'tags-query',
tagValuesQuery: '',
useTags: true,
refresh: VariableRefresh.never,
regex: '',
multi: true,
@ -543,11 +485,3 @@ function createMetric(value: string | string[]) {
text: value,
};
}
function createTag(name: string, values?: any[]) {
return {
selected: false,
text: name,
values,
};
}

View File

@ -1,13 +1,6 @@
import { debounce, trim } from 'lodash';
import { StoreState, ThunkDispatch, ThunkResult } from 'app/types';
import {
QueryVariableModel,
VariableOption,
VariableRefresh,
VariableTag,
VariableWithMultiSupport,
VariableWithOptions,
} from '../../types';
import { VariableOption, VariableWithMultiSupport, VariableWithOptions } from '../../types';
import { variableAdapters } from '../../adapters';
import { getVariable } from '../../state/selectors';
import { NavigationKey } from '../types';
@ -17,13 +10,10 @@ import {
OptionsPickerState,
showOptions,
toggleOption,
toggleTag,
updateOptionsAndFilter,
updateOptionsFromSearch,
updateSearchQuery,
} from './reducer';
import { getDataSourceSrv } from '@grafana/runtime';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { changeVariableProp, setCurrentVariableValue } from '../../state/sharedReducer';
import { toVariablePayload, VariableIdentifier } from '../../state/types';
import { containsSearchFilter, getCurrentText } from '../../utils';
@ -124,46 +114,6 @@ export const toggleOptionByHighlight = (clearOthers: boolean, forceSelect = fals
};
};
export const toggleAndFetchTag = (tag: VariableTag): ThunkResult<void> => {
return async (dispatch, getState) => {
if (Array.isArray(tag.values)) {
return dispatch(toggleTag(tag));
}
const values = await dispatch(fetchTagValues(tag.text.toString()));
return dispatch(toggleTag({ ...tag, values }));
};
};
const fetchTagValues = (tagText: string): ThunkResult<Promise<string[]>> => {
return async (dispatch, getState) => {
const picker = getState().templating.optionsPicker;
const variable = getVariable<QueryVariableModel>(picker.id, getState());
const datasource = await getDataSourceSrv().get(variable.datasource ?? '');
const query = variable.tagValuesQuery.replace(/\$tag/g, tagText);
const options = { range: getTimeRange(variable), variable };
if (!datasource.metricFindQuery) {
return [];
}
const results = await datasource.metricFindQuery(query, options);
if (!Array.isArray(results)) {
return [];
}
return results.map((value) => value.text);
};
};
const getTimeRange = (variable: QueryVariableModel) => {
if (variable.refresh === VariableRefresh.onTimeRangeChanged || variable.refresh === VariableRefresh.onDashboardLoad) {
return getTimeSrv().timeRange();
}
return undefined;
};
const searchForOptions = async (dispatch: ThunkDispatch, getState: () => StoreState, searchQuery: string) => {
try {
const { id } = getState().templating.optionsPicker;
@ -207,7 +157,6 @@ export function mapToCurrent(picker: OptionsPickerState): VariableOption | undef
return {
value: values,
text: texts,
tags: picker.tags.filter((t) => t.selected),
selected: true,
};
}

View File

@ -10,13 +10,12 @@ import {
showOptions,
toggleAllOptions,
toggleOption,
toggleTag,
updateOptionsAndFilter,
updateOptionsFromSearch,
updateSearchQuery,
} from './reducer';
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import { QueryVariableModel, VariableOption, VariableTag } from '../../types';
import { QueryVariableModel, VariableOption } from '../../types';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from '../../state/types';
const getVariableTestContext = (extend: Partial<OptionsPickerState>) => {
@ -357,168 +356,6 @@ describe('optionsPickerReducer', () => {
});
});
describe('when toggleTag is dispatched', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
options: [
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
});
const payload: VariableTag = { text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] };
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleTag(payload))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
tags: [
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'], valuesText: 'A + AA + AAA' },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
selectedValues: [
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
],
});
});
});
describe('when toggleTag is dispatched when tag is selected', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({
tags: [
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
options: [
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
});
const payload: VariableTag = { text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'] };
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleTag(payload))
.thenStateShouldEqual({
...initialState,
options: [
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
selectedValues: [],
});
});
});
describe('when toggleTag is dispatched and ALL is previous selected', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
options: [
{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE },
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
});
const payload: VariableTag = { text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] };
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleTag(payload))
.thenStateShouldEqual({
...initialState,
options: [
{ text: ALL_VARIABLE_TEXT, selected: false, value: ALL_VARIABLE_VALUE },
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
tags: [
{ text: 'All A:s', selected: true, values: ['A', 'AA', 'AAA'], valuesText: 'A + AA + AAA' },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
],
selectedValues: [
{ text: 'A', selected: true, value: 'A' },
{ text: 'AA', selected: true, value: 'AA' },
{ text: 'AAA', selected: true, value: 'AAA' },
],
});
});
});
describe('when toggleTag is dispatched and only the tag is previous selected', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
{ text: 'All D:s', selected: true, values: ['D'] },
],
options: [
{ text: ALL_VARIABLE_TEXT, selected: false, value: ALL_VARIABLE_VALUE },
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
});
const payload: VariableTag = { text: 'All D:s', selected: true, values: ['D'] };
reducerTester<OptionsPickerState>()
.givenReducer(optionsPickerReducer, cloneDeep(initialState))
.whenActionIsDispatched(toggleTag(payload))
.thenStateShouldEqual({
...initialState,
options: [
{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE },
{ text: 'A', selected: false, value: 'A' },
{ text: 'AA', selected: false, value: 'AA' },
{ text: 'AAA', selected: false, value: 'AAA' },
{ text: 'B', selected: false, value: 'B' },
],
tags: [
{ text: 'All A:s', selected: false, values: ['A', 'AA', 'AAA'] },
{ text: 'All B:s', selected: false, values: ['B', 'BB', 'BBB'] },
{ text: 'All C:s', selected: false, values: ['C', 'CC', 'CCC'] },
{ text: 'All D:s', selected: false, values: ['D'] },
],
selectedValues: [{ text: ALL_VARIABLE_TEXT, selected: true, value: ALL_VARIABLE_VALUE }],
});
});
});
describe('when changeQueryVariableHighlightIndex is dispatched with -1 and highlightIndex is 0', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext({ highlightIndex: 0 });

View File

@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { cloneDeep, isString, trim } from 'lodash';
import { VariableOption, VariableTag, VariableWithMultiSupport, VariableWithOptions } from '../../types';
import { VariableOption, VariableWithOptions } from '../../types';
import { ALL_VARIABLE_VALUE } from '../../state/types';
import { isMulti, isQuery } from '../../guard';
import { applyStateChanges } from '../../../../core/utils/applyStateChanges';
@ -15,10 +15,8 @@ export interface ToggleOption {
export interface OptionsPickerState {
id: string;
selectedValues: VariableOption[];
selectedTags: VariableTag[];
queryValue: string;
highlightIndex: number;
tags: VariableTag[];
options: VariableOption[];
multi: boolean;
}
@ -27,22 +25,13 @@ export const initialState: OptionsPickerState = {
id: '',
highlightIndex: -1,
queryValue: '',
selectedTags: [],
selectedValues: [],
tags: [],
options: [],
multi: false,
};
export const OPTIONS_LIMIT = 1000;
const getTags = (model: VariableWithMultiSupport) => {
if (isQuery(model) && Array.isArray(model.tags)) {
return cloneDeep(model.tags);
}
return [];
};
const optionsToRecord = (options: VariableOption[]): Record<string, VariableOption> => {
if (!Array.isArray(options)) {
return {};
@ -129,7 +118,6 @@ const optionsPickerSlice = createSlice({
state.multi = false;
if (isMulti(action.payload)) {
state.tags = getTags(action.payload);
state.multi = action.payload.multi ?? false;
}
@ -172,44 +160,6 @@ const optionsPickerSlice = createSlice({
return applyStateChanges(state, updateDefaultSelection, updateAllSelection, updateOptions);
},
toggleTag: (state, action: PayloadAction<VariableTag>): OptionsPickerState => {
const tag = action.payload;
const values = tag.values || [];
const selected = !tag.selected;
state.tags = state.tags.map((t) => {
if (t.text !== tag.text) {
return t;
}
t.selected = selected;
t.values = values;
if (selected) {
t.valuesText = values.join(' + ');
} else {
delete t.valuesText;
}
return t;
});
const availableOptions = optionsToRecord(state.options);
if (!selected) {
state.selectedValues = state.selectedValues.filter(
(option) => !isString(option.value) || !availableOptions[option.value]
);
return applyStateChanges(state, updateDefaultSelection, updateOptions);
}
const optionsFromTag = values
.filter((value) => value !== ALL_VARIABLE_VALUE && !!availableOptions[value])
.map((value) => ({ selected, value, text: value }));
state.selectedValues.push.apply(state.selectedValues, optionsFromTag);
return applyStateChanges(state, updateDefaultSelection, updateOptions);
},
moveOptionsHighlight: (state, action: PayloadAction<number>): OptionsPickerState => {
let nextIndex = state.highlightIndex + action.payload;
@ -270,7 +220,6 @@ export const {
toggleOption,
showOptions,
hideOptions,
toggleTag,
moveOptionsHighlight,
toggleAllOptions,
updateSearchQuery,

View File

@ -1,20 +1,17 @@
import React, { FC, MouseEvent, useCallback } from 'react';
import { css } from '@emotion/css';
import { getTagColorsFromName, Icon, Tooltip, useStyles } from '@grafana/ui';
import { Icon, Tooltip, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { GrafanaTheme } from '@grafana/data';
import { VariableTag } from '../../types';
interface Props {
onClick: () => void;
text: string;
tags: VariableTag[];
loading: boolean;
onCancel: () => void;
}
export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, tags, text, onCancel }) => {
export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, text, onCancel }) => {
const styles = useStyles(getStyles);
const onClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
@ -32,7 +29,7 @@ export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, tags,
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
title={text}
>
<VariableLinkText tags={tags} text={text} />
<VariableLinkText text={text} />
<LoadingIndicator onCancel={onCancel} />
</div>
);
@ -45,31 +42,19 @@ export const VariableLink: FC<Props> = ({ loading, onClick: propsOnClick, tags,
aria-label={selectors.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(`${text}`)}
title={text}
>
<VariableLinkText tags={tags} text={text} />
<VariableLinkText text={text} />
<Icon name="angle-down" size="sm" />
</a>
);
};
const VariableLinkText: FC<Pick<Props, 'tags' | 'text'>> = ({ tags, text }) => {
interface VariableLinkTextProps {
text: string;
}
const VariableLinkText: FC<VariableLinkTextProps> = ({ text }) => {
const styles = useStyles(getStyles);
return (
<span className={styles.textAndTags}>
{text}
{tags.map((tag) => {
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
return (
<span key={`${tag.text}`}>
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
&nbsp;&nbsp;
<Icon name="tag-alt" />
&nbsp; {tag.text}
</span>
</span>
);
})}
</span>
);
return <span className={styles.textAndTags}>{text}</span>;
};
const LoadingIndicator: FC<Pick<Props, 'onCancel'>> = ({ onCancel }) => {

View File

@ -1,18 +1,16 @@
import React, { PureComponent } from 'react';
import { getTagColorsFromName, Icon, Tooltip } from '@grafana/ui';
import { Tooltip } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { VariableOption, VariableTag } from '../../types';
import { VariableOption } from '../../types';
export interface Props {
multi: boolean;
values: VariableOption[];
selectedValues: VariableOption[];
tags: VariableTag[];
highlightIndex: number;
onToggle: (option: VariableOption, clearOthers: boolean) => void;
onToggleAll: () => void;
onToggleTag: (tag: VariableTag) => void;
}
export class VariableOptions extends PureComponent<Props> {
@ -27,18 +25,13 @@ export class VariableOptions extends PureComponent<Props> {
this.props.onToggleAll();
};
onToggleTag = (tag: VariableTag) => (event: React.MouseEvent<HTMLAnchorElement>) => {
this.handleEvent(event);
this.props.onToggleTag(tag);
};
handleEvent(event: React.MouseEvent<HTMLAnchorElement>) {
event.preventDefault();
event.stopPropagation();
}
render() {
const { multi, values, tags } = this.props;
const { multi, values } = this.props;
return (
<div
@ -50,44 +43,11 @@ export class VariableOptions extends PureComponent<Props> {
{this.renderMultiToggle()}
{values.map((option, index) => this.renderOption(option, index))}
</div>
{this.renderTags(tags)}
</div>
</div>
);
}
renderTags(tags: VariableTag[]) {
if (tags.length === 0) {
return null;
}
return (
<div className="variable-options-column">
<div className="variable-options-column-header text-center">Tags</div>
{tags.map((tag) => this.renderTag(tag))}
</div>
);
}
renderTag(tag: VariableTag) {
const { color, borderColor } = getTagColorsFromName(tag.text.toString());
return (
<a
key={`${tag.text}`}
className={`${tag.selected ? 'variable-option-tag pointer selected' : 'variable-option-tag pointer'}`}
onClick={this.onToggleTag(tag)}
>
<span className="variable-option-icon"></span>
<span className="label-tag" style={{ backgroundColor: color, borderColor }}>
{tag.text}&nbsp;&nbsp;
<Icon name="tag-alt" />
&nbsp;
</span>
</a>
);
}
renderOption(option: VariableOption, index: number) {
const { highlightIndex } = this.props;
const selectClass = option.selected ? 'variable-option pointer selected' : 'variable-option pointer';

View File

@ -13,7 +13,7 @@ import { setDataSourceSrv } from '@grafana/runtime';
const setupTestContext = (options: Partial<Props>) => {
const defaults: Props = {
variable: { ...initialQueryVariableModelState, useTags: true },
variable: { ...initialQueryVariableModelState },
initQueryVariableEditor: jest.fn(),
changeQueryVariableDataSource: jest.fn(),
changeQueryVariableQuery: jest.fn(),
@ -54,8 +54,6 @@ describe('QueryVariableEditor', () => {
fieldName | propName | expectedArgs
${'query'} | ${'changeQueryVariableQuery'} | ${[{ type: 'query', id: NEW_VARIABLE_ID }, 't', 't']}
${'regex'} | ${'onPropChange'} | ${{ propName: 'regex', propValue: 't', updateOptions: true }}
${'tagsQuery'} | ${'onPropChange'} | ${{ propName: 'tagsQuery', propValue: 't', updateOptions: true }}
${'tagValuesQuery'} | ${'onPropChange'} | ${{ propName: 'tagValuesQuery', propValue: 't', updateOptions: true }}
`(
'$fieldName field and tabs away then $propName should be called with correct args',
({ fieldName, propName, expectedArgs }) => {
@ -77,8 +75,6 @@ describe('QueryVariableEditor', () => {
fieldName | propName
${'query'} | ${'changeQueryVariableQuery'}
${'regex'} | ${'onPropChange'}
${'tagsQuery'} | ${'onPropChange'}
${'tagValuesQuery'} | ${'onPropChange'}
`(
'$fieldName field but reverts the change and tabs away then $propName should not be called',
({ fieldName, propName }) => {
@ -101,14 +97,7 @@ const getQueryField = () =>
const getRegExField = () => screen.getByRole('textbox', { name: /variable editor form query regex field/i });
const getTagsQueryField = () => screen.getByRole('textbox', { name: /variable editor form query tagsquery field/i });
const getTagValuesQueryField = () =>
screen.getByRole('textbox', { name: /variable editor form query tagsvaluesquery field/i });
const fieldAccessors: Record<string, () => HTMLElement> = {
query: getQueryField,
regex: getRegExField,
tagsQuery: getTagsQueryField,
tagValuesQuery: getTagValuesQueryField,
};

View File

@ -1,9 +1,9 @@
import React, { ChangeEvent, FormEvent, PureComponent } from 'react';
import React, { FormEvent, PureComponent } from 'react';
import { css } from '@emotion/css';
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
import { InlineField, InlineFieldRow, VerticalGroup } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { getTemplateSrv, DataSourcePicker } from '@grafana/runtime';
import { DataSourcePicker, getTemplateSrv } from '@grafana/runtime';
import { DataSourceInstanceSettings, LoadingState, SelectableValue } from '@grafana/data';
import { SelectionOptionsEditor } from '../editor/SelectionOptionsEditor';
@ -20,7 +20,6 @@ import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { isLegacyQueryEditor, isQueryEditor } from '../guard';
import { VariableSectionHeader } from '../editor/VariableSectionHeader';
import { VariableTextField } from '../editor/VariableTextField';
import { VariableSwitchField } from '../editor/VariableSwitchField';
import { QueryVariableRefreshSelect } from './QueryVariableRefreshSelect';
import { QueryVariableSortSelect } from './QueryVariableSortSelect';
@ -102,28 +101,6 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
}
};
onTagsQueryChange = async (event: FormEvent<HTMLInputElement>) => {
this.setState({ tagsQuery: event.currentTarget.value });
};
onTagsQueryBlur = async (event: FormEvent<HTMLInputElement>) => {
const tagsQuery = event.currentTarget.value;
if (this.props.variable.tagsQuery !== tagsQuery) {
this.props.onPropChange({ propName: 'tagsQuery', propValue: tagsQuery, updateOptions: true });
}
};
onTagValuesQueryChange = async (event: FormEvent<HTMLInputElement>) => {
this.setState({ tagValuesQuery: event.currentTarget.value });
};
onTagValuesQueryBlur = async (event: FormEvent<HTMLInputElement>) => {
const tagValuesQuery = event.currentTarget.value;
if (this.props.variable.tagValuesQuery !== tagValuesQuery) {
this.props.onPropChange({ propName: 'tagValuesQuery', propValue: tagValuesQuery, updateOptions: true });
}
};
onRefreshChange = (option: SelectableValue<VariableRefresh>) => {
this.props.onPropChange({ propName: 'refresh', propValue: option.value });
};
@ -136,10 +113,6 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
this.props.onPropChange({ propName, propValue, updateOptions: true });
};
onUseTagsChange = async (event: ChangeEvent<HTMLInputElement>) => {
this.props.onPropChange({ propName: 'useTags', propValue: event.target.checked, updateOptions: true });
};
renderQueryEditor = () => {
const { editor, variable } = this.props;
if (!editor.extended || !editor.extended.dataSource || !editor.extended.VariableQueryEditor) {
@ -236,46 +209,6 @@ export class QueryVariableEditorUnConnected extends PureComponent<Props, State>
onPropChange={this.onSelectionOptionsChange}
onMultiChanged={this.props.changeVariableMultiValue}
/>
<VerticalGroup spacing="none">
<h5>Value group tags</h5>
<em className="muted p-b-1">Experimental feature, will be deprecated in Grafana v8.</em>
<VariableSwitchField
value={this.props.variable.useTags}
name="Enabled"
onChange={this.onUseTagsChange}
ariaLabel={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.valueGroupsTagsEnabledSwitch}
/>
{this.props.variable.useTags ? (
<VerticalGroup spacing="none">
<VariableTextField
value={this.state.tagsQuery ?? this.props.variable.tagsQuery}
name="Tags query"
placeholder="metric name or tags query"
onChange={this.onTagsQueryChange}
onBlur={this.onTagsQueryBlur}
ariaLabel={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.valueGroupsTagsTagsQueryInput
}
labelWidth={20}
grow
/>
<VariableTextField
value={this.state.tagValuesQuery ?? this.props.variable.tagValuesQuery}
name="Tag values query"
placeholder="apps.$tag.*"
onChange={this.onTagValuesQueryChange}
onBlur={this.onTagValuesQueryBlur}
ariaLabel={
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.valueGroupsTagsTagsValuesQueryInput
}
labelWidth={20}
grow
/>
</VerticalGroup>
) : null}
</VerticalGroup>
</VerticalGroup>
</VerticalGroup>
);

View File

@ -7,7 +7,7 @@ import { queryBuilder } from '../shared/testing/builders';
import { QueryRunner, QueryRunners } from './queryRunners';
import { toVariableIdentifier, VariableIdentifier } from '../state/types';
import { QueryVariableModel } from '../types';
import { updateVariableOptions, updateVariableTags } from './reducer';
import { updateVariableOptions } from './reducer';
type DoneCallback = {
(...args: any[]): any;
@ -146,56 +146,6 @@ describe('VariableQueryRunner', () => {
});
});
describe('tags case', () => {
it('then it should work as expected', (done) => {
const variable = queryBuilder().withId('query').withTags(true).withTagsQuery('A tags query').build();
const {
identifier,
runner,
datasource,
getState,
getVariable,
queryRunners,
queryRunner,
dispatch,
} = getTestContext(variable);
expectOnResults({
identifier,
runner,
expect: (results) => {
// verify that the observable works as expected
expect(results).toEqual([
{ state: LoadingState.Loading, identifier },
{ state: LoadingState.Done, identifier },
]);
// verify that mocks have been called as expected
expect(getState).toHaveBeenCalledTimes(3);
expect(getVariable).toHaveBeenCalledTimes(1);
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1);
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1);
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1);
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1);
// updateVariableOptions, updateVariableTags and validateVariableSelectionState
expect(dispatch).toHaveBeenCalledTimes(3);
expect(dispatch.mock.calls[0][0]).toEqual(
updateVariableOptions({
id: 'query',
type: 'query',
data: { results: [], templatedRegex: 'getTemplatedRegex result' },
})
);
expect(dispatch.mock.calls[1][0]).toEqual(updateVariableTags({ id: 'query', type: 'query', data: [] }));
},
done,
});
runner.queueRequest({ identifier, datasource });
});
});
describe('error cases', () => {
describe('queryRunners.getRunnerForDatasource throws', () => {
it('then it should work as expected', (done) => {
@ -280,48 +230,6 @@ describe('VariableQueryRunner', () => {
runner.queueRequest({ identifier, datasource });
});
});
describe('metricFindQuery throws', () => {
it('then it should work as expected', (done) => {
const variable = queryBuilder().withId('query').withTags(true).withTagsQuery('A tags query').build();
const {
identifier,
runner,
datasource,
getState,
getVariable,
queryRunners,
queryRunner,
dispatch,
} = getTestContext(variable);
datasource.metricFindQuery = jest.fn().mockRejectedValue(new Error('metricFindQuery error'));
expectOnResults({
identifier,
runner,
expect: (results) => {
// verify that the observable works as expected
expect(results).toEqual([
{ state: LoadingState.Loading, identifier },
{ state: LoadingState.Error, identifier, error: new Error('metricFindQuery error') },
]);
// verify that mocks have been called as expected
expect(getState).toHaveBeenCalledTimes(3);
expect(getVariable).toHaveBeenCalledTimes(1);
expect(queryRunners.getRunnerForDatasource).toHaveBeenCalledTimes(1);
expect(queryRunner.getTarget).toHaveBeenCalledTimes(1);
expect(queryRunner.runRequest).toHaveBeenCalledTimes(1);
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(1);
},
done,
});
runner.queueRequest({ identifier, datasource });
});
});
});
describe('cancellation cases', () => {

View File

@ -20,13 +20,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { QueryRunners } from './queryRunners';
import { runRequest } from '../../query/state/runRequest';
import {
runUpdateTagsRequest,
toMetricFindValues,
updateOptionsState,
updateTagsState,
validateVariableSelection,
} from './operators';
import { toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators';
interface UpdateOptionsArgs {
identifier: VariableIdentifier;
@ -133,8 +127,6 @@ export class VariableQueryRunner {
}),
toMetricFindValues(),
updateOptionsState({ variable, dispatch, getTemplatedRegexFunc }),
runUpdateTagsRequest({ variable, datasource, searchFilter }),
updateTagsState({ variable, dispatch }),
validateVariableSelection({ variable, dispatch, searchFilter }),
takeUntil(
merge(this.updateOptionsRequests, this.cancelRequests).pipe(

View File

@ -22,7 +22,7 @@ import {
initQueryVariableEditor,
updateQueryVariableOptions,
} from './actions';
import { updateVariableOptions, updateVariableTags } from './reducer';
import { updateVariableOptions } from './reducer';
import {
addVariableEditorError,
changeVariableEditorExtended,
@ -78,64 +78,12 @@ describe('query actions', () => {
variableAdapters.setInit(() => [createQueryVariableAdapter()]);
describe('when updateQueryVariableOptions is dispatched for variable with tags and includeAll', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true });
const optionsMetrics = [createMetric('A'), createMetric('B')];
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsShouldEqual(
updateVariableOptions(toVariablePayload(variable, update)),
updateVariableTags(toVariablePayload(variable, tagsMetrics)),
setCurrentVariableValue(toVariablePayload(variable, { option }))
);
});
});
describe('when updateQueryVariableOptions is dispatched for variable with tags', () => {
describe('when updateQueryVariableOptions is dispatched for variable without both tags and includeAll', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: false });
const optionsMetrics = [createMetric('A'), createMetric('B')];
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
mockDatasourceMetrics(variable, optionsMetrics, tagsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toVariablePayload(variable)), true);
const option = createOption('A');
const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsPredicateShouldEqual((actions) => {
const [updateOptions, updateTags, setCurrentAction] = actions;
const expectedNumberOfActions = 3;
expect(updateOptions).toEqual(updateVariableOptions(toVariablePayload(variable, update)));
expect(updateTags).toEqual(updateVariableTags(toVariablePayload(variable, tagsMetrics)));
expect(setCurrentAction).toEqual(setCurrentVariableValue(toVariablePayload(variable, { option })));
return actions.length === expectedNumberOfActions;
});
});
});
describe('when updateQueryVariableOptions is dispatched for variable without both tags and includeAll', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: false, useTags: false });
const optionsMetrics = [createMetric('A'), createMetric('B')];
mockDatasourceMetrics(variable, optionsMetrics, []);
mockDatasourceMetrics(variable, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -154,10 +102,10 @@ describe('query actions', () => {
describe('when updateQueryVariableOptions is dispatched for variable with includeAll but without tags', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const optionsMetrics = [createMetric('A'), createMetric('B')];
mockDatasourceMetrics(variable, optionsMetrics, []);
mockDatasourceMetrics(variable, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -180,10 +128,10 @@ describe('query actions', () => {
describe('when updateQueryVariableOptions is dispatched for variable open in editor', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const optionsMetrics = [createMetric('A'), createMetric('B')];
mockDatasourceMetrics(variable, optionsMetrics, []);
mockDatasourceMetrics(variable, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -208,10 +156,10 @@ describe('query actions', () => {
describe('when updateQueryVariableOptions is dispatched for variable with searchFilter', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const optionsMetrics = [createMetric('A'), createMetric('B')];
mockDatasourceMetrics(variable, optionsMetrics, []);
mockDatasourceMetrics(variable, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -235,7 +183,7 @@ describe('query actions', () => {
describe('when updateQueryVariableOptions is dispatched and fails for variable open in editor', () => {
silenceConsoleOutput();
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const error = { message: 'failed to fetch metrics' };
mocks[variable.datasource!].metricFindQuery = jest.fn(() => Promise.reject(error));
@ -267,7 +215,7 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const testMetricSource = { name: 'test', value: 'test', meta: {} };
const editor = {};
@ -296,7 +244,7 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched and metricsource without value is available', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const testMetricSource = { name: 'test', value: (null as unknown) as string, meta: {} };
const editor = {};
@ -325,7 +273,7 @@ describe('query actions', () => {
describe('when initQueryVariableEditor is dispatched and no metric sources was found', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ includeAll: true, useTags: false });
const variable = createVariable({ includeAll: true });
const editor = {};
mocks.dataSourceSrv.getList = jest.fn().mockReturnValue([]);
@ -433,13 +381,12 @@ describe('query actions', () => {
describe('when changeQueryVariableQuery is dispatched', () => {
it('then correct actions are dispatched', async () => {
const optionsMetrics = [createMetric('A'), createMetric('B')];
const tagsMetrics = [createMetric('tagA'), createMetric('tagB')];
const variable = createVariable({ datasource: 'datasource', useTags: true, includeAll: true });
const variable = createVariable({ datasource: 'datasource', includeAll: true });
const query = '$datasource';
const definition = 'depends on datasource variable';
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, tagsMetrics);
mockDatasourceMetrics({ ...variable, query }, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -455,7 +402,6 @@ describe('query actions', () => {
changeVariableProp(toVariablePayload(variable, { propName: 'definition', propValue: definition })),
variableStateFetching(toVariablePayload(variable)),
updateVariableOptions(toVariablePayload(variable, update)),
updateVariableTags(toVariablePayload(variable, tagsMetrics)),
setCurrentVariableValue(toVariablePayload(variable, { option })),
variableStateCompleted(toVariablePayload(variable))
);
@ -465,12 +411,12 @@ describe('query actions', () => {
describe('when changeQueryVariableQuery is dispatched for variable without tags', () => {
it('then correct actions are dispatched', async () => {
const optionsMetrics = [createMetric('A'), createMetric('B')];
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: true });
const variable = createVariable({ datasource: 'datasource', includeAll: true });
const query = '$datasource';
const definition = 'depends on datasource variable';
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []);
mockDatasourceMetrics({ ...variable, query }, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -495,11 +441,11 @@ describe('query actions', () => {
describe('when changeQueryVariableQuery is dispatched for variable without tags and all', () => {
it('then correct actions are dispatched', async () => {
const optionsMetrics = [createMetric('A'), createMetric('B')];
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: false });
const variable = createVariable({ datasource: 'datasource', includeAll: false });
const query = '$datasource';
const definition = 'depends on datasource variable';
mockDatasourceMetrics({ ...variable, query }, optionsMetrics, []);
mockDatasourceMetrics({ ...variable, query }, optionsMetrics);
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
@ -523,7 +469,7 @@ describe('query actions', () => {
describe('when changeQueryVariableQuery is dispatched with invalid query', () => {
it('then correct actions are dispatched', async () => {
const variable = createVariable({ datasource: 'datasource', useTags: false, includeAll: false });
const variable = createVariable({ datasource: 'datasource', includeAll: false });
const query = `$${variable.name}`;
const definition = 'depends on datasource variable';
@ -701,10 +647,9 @@ describe('query actions', () => {
});
});
function mockDatasourceMetrics(variable: QueryVariableModel, optionsMetrics: any[], tagsMetrics: any[]) {
function mockDatasourceMetrics(variable: QueryVariableModel, optionsMetrics: any[]) {
const metrics: Record<string, any[]> = {
[variable.query]: optionsMetrics,
[variable.tagsQuery]: tagsMetrics,
};
const { metricFindQuery } = mocks[variable.datasource!];
@ -729,10 +674,6 @@ function createVariable(extend?: Partial<QueryVariableModel>): QueryVariableMode
datasource: 'datasource',
definition: '',
sort: VariableSort.alphabeticalAsc,
tags: [],
tagsQuery: 'tags-query',
tagValuesQuery: '',
useTags: true,
refresh: VariableRefresh.onDashboardLoad,
regex: '',
multi: true,

View File

@ -1,17 +1,8 @@
import { of } from 'rxjs';
import { queryBuilder } from '../shared/testing/builders';
import { FieldType, toDataFrame } from '@grafana/data';
import { initialQueryVariableModelState, updateVariableOptions, updateVariableTags } from './reducer';
import { toVariablePayload } from '../state/types';
import { VariableRefresh } from '../types';
import {
areMetricFindValues,
runUpdateTagsRequest,
toMetricFindValues,
updateOptionsState,
updateTagsState,
validateVariableSelection,
} from './operators';
import { updateVariableOptions } from './reducer';
import { areMetricFindValues, toMetricFindValues, updateOptionsState, validateVariableSelection } from './operators';
describe('operators', () => {
beforeEach(() => {
@ -33,122 +24,6 @@ describe('operators', () => {
});
});
describe('updateTagsState', () => {
describe('when called with a variable that uses Tags', () => {
it('then the correct observable should be created', async () => {
const variable = queryBuilder().withId('query').withTags(true).build();
const dispatch = jest.fn().mockResolvedValue({});
const observable = of([{ text: 'A text' }]).pipe(updateTagsState({ variable, dispatch }));
await expect(observable).toEmitValuesWith((received) => {
const value = received[0];
expect(value).toEqual(undefined);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(updateVariableTags(toVariablePayload(variable, [{ text: 'A text' }])));
});
});
});
describe('when called with a variable that does not use Tags', () => {
it('then the correct observable should be created', async () => {
const variable = queryBuilder().withId('query').withTags(false).build();
const dispatch = jest.fn().mockResolvedValue({});
const observable = of([{ text: 'A text' }]).pipe(updateTagsState({ variable, dispatch }));
await expect(observable).toEmitValuesWith((received) => {
const value = received[0];
expect(value).toEqual(undefined);
expect(dispatch).not.toHaveBeenCalled();
});
});
});
});
describe('runUpdateTagsRequest', () => {
describe('when called with a datasource with metricFindQuery and variable that uses Tags and refreshes on time range changes', () => {
it('then the correct observable should be created', async () => {
const variable = queryBuilder()
.withId('query')
.withTags(true)
.withTagsQuery('A tags query')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const timeSrv: any = {
timeRange: jest.fn(),
};
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A text' }]) };
const searchFilter = 'A search filter';
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv));
await expect(observable).toEmitValuesWith((received) => {
const value = received[0];
const { index, global, ...rest } = initialQueryVariableModelState;
expect(value).toEqual([{ text: 'A text' }]);
expect(timeSrv.timeRange).toHaveBeenCalledTimes(1);
expect(datasource.metricFindQuery).toHaveBeenCalledTimes(1);
expect(datasource.metricFindQuery).toHaveBeenCalledWith('A tags query', {
range: undefined,
searchFilter: 'A search filter',
variable: {
...rest,
id: 'query',
name: 'query',
useTags: true,
tagsQuery: 'A tags query',
refresh: VariableRefresh.onTimeRangeChanged,
},
});
});
});
});
describe('when called with a datasource without metricFindQuery and variable that uses Tags and refreshes on time range changes', () => {
it('then the correct observable should be created', async () => {
const variable = queryBuilder()
.withId('query')
.withTags(true)
.withTagsQuery('A tags query')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const timeSrv: any = {
timeRange: jest.fn(),
};
const datasource: any = {};
const searchFilter = 'A search filter';
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv));
await expect(observable).toEmitValuesWith((received) => {
const value = received[0];
expect(value).toEqual([]);
expect(timeSrv.timeRange).not.toHaveBeenCalled();
});
});
});
describe('when called with a datasource with metricFindQuery and variable that does not use Tags but refreshes on time range changes', () => {
it('then the correct observable should be created', async () => {
const variable = queryBuilder()
.withId('query')
.withTags(false)
.withRefresh(VariableRefresh.onTimeRangeChanged)
.build();
const timeSrv: any = {
timeRange: jest.fn(),
};
const datasource: any = { metricFindQuery: jest.fn().mockResolvedValue([{ text: 'A text' }]) };
const searchFilter = 'A search filter';
const observable = of(undefined).pipe(runUpdateTagsRequest({ variable, datasource, searchFilter }, timeSrv));
await expect(observable).toEmitValuesWith((received) => {
const value = received[0];
expect(value).toEqual([]);
expect(timeSrv.timeRange).not.toHaveBeenCalled();
expect(datasource.metricFindQuery).not.toHaveBeenCalled();
});
});
});
});
describe('updateOptionsState', () => {
describe('when called', () => {
it('then the correct observable should be created', async () => {

View File

@ -5,10 +5,9 @@ import { QueryVariableModel } from '../types';
import { ThunkDispatch } from '../../../types';
import { toVariableIdentifier, toVariablePayload } from '../state/types';
import { validateVariableSelectionState } from '../state/actions';
import { DataSourceApi, FieldType, getFieldDisplayName, isDataFrame, MetricFindValue, PanelData } from '@grafana/data';
import { updateVariableOptions, updateVariableTags } from './reducer';
import { getTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { getLegacyQueryOptions, getTemplatedRegex } from '../utils';
import { FieldType, getFieldDisplayName, isDataFrame, MetricFindValue, PanelData } from '@grafana/data';
import { updateVariableOptions } from './reducer';
import { getTemplatedRegex } from '../utils';
import { getProcessedDataFrames } from 'app/features/query/state/runRequest';
export function toMetricFindValues(): OperatorFunction<PanelData, MetricFindValue[]> {
@ -110,46 +109,6 @@ export function updateOptionsState(args: {
);
}
export function runUpdateTagsRequest(
args: {
variable: QueryVariableModel;
datasource: DataSourceApi;
searchFilter?: string;
},
timeSrv: TimeSrv = getTimeSrv()
): OperatorFunction<void, MetricFindValue[]> {
return (source) =>
source.pipe(
mergeMap(() => {
const { datasource, searchFilter, variable } = args;
if (variable.useTags && datasource.metricFindQuery) {
return from(
datasource.metricFindQuery(variable.tagsQuery, getLegacyQueryOptions(variable, searchFilter, timeSrv))
);
}
return of([]);
})
);
}
export function updateTagsState(args: {
variable: QueryVariableModel;
dispatch: ThunkDispatch;
}): OperatorFunction<MetricFindValue[], void> {
return (source) =>
source.pipe(
map((tagResults) => {
const { dispatch, variable } = args;
if (variable.useTags) {
dispatch(updateVariableTags(toVariablePayload(variable, tagResults)));
}
})
);
}
export function validateVariableSelection(args: {
variable: QueryVariableModel;
dispatch: ThunkDispatch;

View File

@ -4,7 +4,6 @@ import {
queryVariableReducer,
sortVariableValues,
updateVariableOptions,
updateVariableTags,
} from './reducer';
import { QueryVariableModel, VariableSort } from '../types';
import { cloneDeep } from 'lodash';
@ -261,27 +260,6 @@ describe('queryVariableReducer', () => {
});
});
});
describe('when updateVariableTags is dispatched', () => {
it('then state should be correct', () => {
const { initialState } = getVariableTestContext(adapter);
const tags: any[] = [{ text: 'A' }, { text: 'B' }];
const payload = toVariablePayload({ id: '0', type: 'query' }, tags);
reducerTester<VariablesState>()
.givenReducer(queryVariableReducer, cloneDeep(initialState))
.whenActionIsDispatched(updateVariableTags(payload))
.thenStateShouldEqual({
...initialState,
'0': ({
...initialState[0],
tags: [
{ text: 'A', selected: false },
{ text: 'B', selected: false },
],
} as unknown) as QueryVariableModel,
});
});
});
});
describe('sortVariableValues', () => {

View File

@ -9,17 +9,16 @@ import {
VariableQueryEditorType,
VariableRefresh,
VariableSort,
VariableTag,
} from '../types';
import {
ALL_VARIABLE_TEXT,
ALL_VARIABLE_VALUE,
getInstanceState,
initialVariablesState,
NONE_VARIABLE_TEXT,
NONE_VARIABLE_VALUE,
VariablePayload,
initialVariablesState,
VariablesState,
} from '../state/types';
@ -46,10 +45,6 @@ export const initialQueryVariableModelState: QueryVariableModel = {
allValue: null,
options: [],
current: {} as VariableOption,
tags: [],
useTags: false,
tagsQuery: '',
tagValuesQuery: '',
definition: '',
};
@ -179,19 +174,9 @@ export const queryVariableSlice = createSlice({
instanceState.options = options;
},
updateVariableTags: (state: VariablesState, action: PayloadAction<VariablePayload<any[]>>) => {
const instanceState = getInstanceState<QueryVariableModel>(state, action.payload.id);
const results = action.payload.data;
const tags: VariableTag[] = [];
for (let i = 0; i < results.length; i++) {
tags.push({ text: results[i].text, selected: false });
}
instanceState.tags = tags;
},
},
});
export const queryVariableReducer = queryVariableSlice.reducer;
export const { updateVariableOptions, updateVariableTags } = queryVariableSlice.actions;
export const { updateVariableOptions } = queryVariableSlice.actions;

View File

@ -6,41 +6,13 @@ export const formatVariableLabel = (variable: VariableModel) => {
return variable.name;
}
const { current, options = [] } = variable;
const { current } = variable;
if (!current.tags || current.tags.length === 0) {
if (Array.isArray(current.text)) {
return current.text.join(' + ');
}
return current.text;
}
// filer out values that are in selected tags
const selectedAndNotInTag = options.filter((option) => {
if (!option.selected) {
return false;
}
if (!current || !current.tags || !current.tags.length) {
return false;
}
for (let i = 0; i < current.tags.length; i++) {
const tag = current.tags[i];
const foundIndex = tag?.values?.findIndex((v) => v === option.value);
if (foundIndex && foundIndex !== -1) {
return false;
}
}
return true;
});
// convert values to text
const currentTexts = selectedAndNotInTag.map((s) => s.text);
// join texts
const newLinkText = currentTexts.join(' + ');
return newLinkText.length > 0 ? `${newLinkText} + ` : newLinkText;
};
const isVariableWithOptions = (variable: VariableModel): variable is VariableWithOptions => {

View File

@ -2,16 +2,6 @@ import { QueryVariableModel } from 'app/features/variables/types';
import { DatasourceVariableBuilder } from './datasourceVariableBuilder';
export class QueryVariableBuilder<T extends QueryVariableModel> extends DatasourceVariableBuilder<T> {
withTags(useTags: boolean) {
this.variable.useTags = useTags;
return this;
}
withTagsQuery(tagsQuery: string) {
this.variable.tagsQuery = tagsQuery;
return this;
}
withDatasource(datasource: string) {
this.variable.datasource = datasource;
return this;

View File

@ -1,11 +1,10 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { defaults as lodashDefaults, cloneDeep } from 'lodash';
import { cloneDeep, defaults as lodashDefaults } from 'lodash';
import { LoadingState, VariableType } from '@grafana/data';
import { VariableModel, VariableOption, VariableWithOptions } from '../types';
import { AddVariable, getInstanceState, VariablePayload, initialVariablesState, VariablesState } from './types';
import { AddVariable, getInstanceState, initialVariablesState, VariablePayload, VariablesState } from './types';
import { variableAdapters } from '../adapters';
import { changeVariableNameSucceeded } from '../editor/reducer';
import { isQuery } from '../guard';
import { ensureStringValues } from '../utils';
const sharedReducerSlice = createSlice({
@ -140,19 +139,6 @@ const sharedReducerSlice = createSlice({
option.selected = selected;
return option;
});
if (hasTags(current) && isQuery(instanceState)) {
const selected = current!.tags!.reduce((all: Record<string, boolean>, tag) => {
all[tag.text.toString()] = tag.selected;
return all;
}, {});
instanceState.tags = instanceState.tags.map((t) => {
const text = t.text.toString();
t.selected = selected[text];
return t;
});
}
},
changeVariableProp: (
state: VariablesState,
@ -184,7 +170,3 @@ export const {
variableStateCompleted,
variableStateFailed,
} = sharedReducerSlice.actions;
const hasTags = (option: VariableOption): boolean => {
return Array.isArray(option.tags);
};

View File

@ -33,19 +33,11 @@ export enum VariableSort {
alphabeticalCaseInsensitiveDesc,
}
export interface VariableTag {
selected: boolean;
text: string | string[];
values?: any[];
valuesText?: string;
}
export interface VariableOption {
selected: boolean;
text: string | string[];
value: string | string[];
isNone?: boolean;
tags?: VariableTag[];
}
export interface AdHocVariableFilter {
@ -78,10 +70,6 @@ export interface QueryVariableModel extends DataSourceVariableModel {
datasource: string | null;
definition: string;
sort: VariableSort;
tags: VariableTag[];
tagsQuery: string;
tagValuesQuery: string;
useTags: boolean;
queryValue?: string;
query: any;
}