mirror of
https://github.com/grafana/grafana.git
synced 2025-07-29 14:12:41 +08:00
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:
@ -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.
|
||||
|
@ -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.*`.
|
@ -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');
|
||||
});
|
||||
|
@ -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 [],
|
||||
|
@ -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 [],
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
|
@ -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 }}>
|
||||
|
||||
<Icon name="tag-alt" />
|
||||
{tag.text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
return <span className={styles.textAndTags}>{text}</span>;
|
||||
};
|
||||
|
||||
const LoadingIndicator: FC<Pick<Props, 'onCancel'>> = ({ onCancel }) => {
|
||||
|
@ -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}
|
||||
<Icon name="tag-alt" />
|
||||
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
renderOption(option: VariableOption, index: number) {
|
||||
const { highlightIndex } = this.props;
|
||||
const selectClass = option.selected ? 'variable-option pointer selected' : 'variable-option pointer';
|
||||
|
@ -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(),
|
||||
@ -51,11 +51,9 @@ describe('QueryVariableEditor', () => {
|
||||
|
||||
describe('when the user changes', () => {
|
||||
it.each`
|
||||
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 | propName | expectedArgs
|
||||
${'query'} | ${'changeQueryVariableQuery'} | ${[{ type: 'query', id: NEW_VARIABLE_ID }, 't', 't']}
|
||||
${'regex'} | ${'onPropChange'} | ${{ propName: 'regex', propValue: 't', updateOptions: true }}
|
||||
`(
|
||||
'$fieldName field and tabs away then $propName should be called with correct args',
|
||||
({ fieldName, propName, expectedArgs }) => {
|
||||
@ -74,11 +72,9 @@ describe('QueryVariableEditor', () => {
|
||||
|
||||
describe('when the user changes', () => {
|
||||
it.each`
|
||||
fieldName | propName
|
||||
${'query'} | ${'changeQueryVariableQuery'}
|
||||
${'regex'} | ${'onPropChange'}
|
||||
${'tagsQuery'} | ${'onPropChange'}
|
||||
${'tagValuesQuery'} | ${'onPropChange'}
|
||||
fieldName | propName
|
||||
${'query'} | ${'changeQueryVariableQuery'}
|
||||
${'regex'} | ${'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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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', () => {
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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 () => {
|
||||
|
@ -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;
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
if (Array.isArray(current.text)) {
|
||||
return current.text.join(' + ');
|
||||
}
|
||||
|
||||
// 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;
|
||||
return current.text;
|
||||
};
|
||||
|
||||
const isVariableWithOptions = (variable: VariableModel): variable is VariableWithOptions => {
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user